use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::adapters::{
ConfigValidator, Severity, ToolAdapter, ValidationIssue, json_utils, validate_all_syntax,
};
use crate::config::{McpConfig, McpServer};
use crate::error::LorumError;
pub struct ContinueDevAdapter {
project_root: Option<PathBuf>,
}
const YAML_MCP_FIELD: &str = "mcpServers";
const JSON_MCP_FIELD: &str = "modelContextProtocolServers";
const JSON_EXPERIMENTAL_FIELD: &str = "experimental";
impl ContinueDevAdapter {
pub fn new() -> Self {
Self { project_root: None }
}
pub fn with_project_root(root: PathBuf) -> Self {
Self {
project_root: Some(root),
}
}
fn project_yaml_path(&self) -> Option<PathBuf> {
let root = self
.project_root
.clone()
.or_else(|| std::env::current_dir().ok())?;
Some(root.join(".continue").join("config.yaml"))
}
fn global_yaml_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".continue").join("config.yaml"))
}
fn project_json_path(&self) -> Option<PathBuf> {
let root = self
.project_root
.clone()
.or_else(|| std::env::current_dir().ok())?;
Some(root.join(".continue").join("config.json"))
}
fn global_json_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".continue").join("config.json"))
}
fn detect_format(&self) -> (PathBuf, ConfigFormat) {
let candidates = [
(self.project_yaml_path(), ConfigFormat::Yaml),
(Self::global_yaml_path(), ConfigFormat::Yaml),
(self.project_json_path(), ConfigFormat::Json),
(Self::global_json_path(), ConfigFormat::Json),
];
for (path, format) in candidates {
if let Some(ref p) = path {
if p.exists() {
return (p.clone(), format);
}
}
}
(
self.project_yaml_path()
.unwrap_or_else(|| PathBuf::from(".continue/config.yaml")),
ConfigFormat::Yaml,
)
}
}
impl Default for ContinueDevAdapter {
fn default() -> Self {
Self::new()
}
}
impl ConfigValidator for ContinueDevAdapter {
fn name(&self) -> &str {
"continue"
}
fn validate_config(&self) -> Result<Vec<ValidationIssue>, LorumError> {
let mut issues = validate_all_syntax(&self.config_paths());
let yaml_paths: Vec<_> = [self.project_yaml_path(), Self::global_yaml_path()]
.into_iter()
.flatten()
.filter(|p| p.exists())
.collect();
let json_paths: Vec<_> = [self.project_json_path(), Self::global_json_path()]
.into_iter()
.flatten()
.filter(|p| p.exists())
.collect();
if !yaml_paths.is_empty() && !json_paths.is_empty() {
issues.push(ValidationIssue {
severity: Severity::Warning,
message: "both .continue/config.yaml and .continue/config.json exist; Continue.dev prefers YAML".into(),
path: None,
line: None,
});
}
Ok(issues)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFormat {
Yaml,
Json,
}
impl ToolAdapter for ContinueDevAdapter {
fn name(&self) -> &str {
"continue"
}
fn config_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(4);
if let Some(p) = self.project_yaml_path() {
paths.push(p);
}
if let Some(p) = Self::global_yaml_path() {
paths.push(p);
}
if let Some(p) = self.project_json_path() {
paths.push(p);
}
if let Some(p) = Self::global_json_path() {
paths.push(p);
}
paths
}
fn read_mcp(&self) -> Result<McpConfig, LorumError> {
let candidates = [
(self.project_yaml_path(), ConfigFormat::Yaml),
(Self::global_yaml_path(), ConfigFormat::Yaml),
(self.project_json_path(), ConfigFormat::Json),
(Self::global_json_path(), ConfigFormat::Json),
];
for (path, format) in candidates {
let Some(p) = path else { continue };
if !p.exists() {
continue;
}
return match format {
ConfigFormat::Yaml => read_mcp_from_yaml(&p),
ConfigFormat::Json => read_mcp_from_json(&p),
};
}
Ok(McpConfig::default())
}
fn write_mcp(&self, config: &McpConfig) -> Result<(), LorumError> {
let (path, format) = self.detect_format();
match format {
ConfigFormat::Yaml => write_mcp_to_yaml(&path, config),
ConfigFormat::Json => write_mcp_to_json(&path, config),
}
}
}
fn read_mcp_from_yaml(path: &Path) -> Result<McpConfig, LorumError> {
let contents = std::fs::read_to_string(path)?;
let root: serde_yaml::Value =
serde_yaml::from_str(&contents).map_err(|e| LorumError::ConfigParse {
format: "yaml".into(),
path: path.to_path_buf(),
source: Box::new(e),
})?;
let Some(servers) = root.get(YAML_MCP_FIELD).and_then(|v| v.as_sequence()) else {
return Ok(McpConfig::default());
};
let mut map = BTreeMap::new();
for (index, entry) in servers.iter().enumerate() {
let name = entry
.get("name")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| format!("unnamed-server-{index}"));
if let Some(server) = parse_mcp_server_from_yaml_value(entry) {
map.insert(name, server);
}
}
Ok(McpConfig { servers: map })
}
fn parse_mcp_server_from_yaml_value(value: &serde_yaml::Value) -> Option<McpServer> {
let obj = value.as_mapping()?;
let command = obj
.get(serde_yaml::Value::String("command".into()))
.and_then(|v| v.as_str())?
.to_string();
let args = obj
.get(serde_yaml::Value::String("args".into()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let env = obj
.get(serde_yaml::Value::String("env".into()))
.and_then(|v| v.as_mapping())
.map(|mapping| {
mapping
.iter()
.filter_map(|(k, v)| {
k.as_str()
.and_then(|key| v.as_str().map(|val| (key.to_string(), val.to_string())))
})
.collect()
})
.unwrap_or_default();
Some(McpServer { command, args, env })
}
fn read_mcp_from_json(path: &Path) -> Result<McpConfig, LorumError> {
let root = json_utils::read_existing_json(path)?;
let Some(servers) = root
.get(JSON_EXPERIMENTAL_FIELD)
.and_then(|v| v.get(JSON_MCP_FIELD))
.and_then(|v| v.as_array())
else {
return Ok(McpConfig::default());
};
let mut map = BTreeMap::new();
for (index, entry) in servers.iter().enumerate() {
let name = entry
.get("name")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| format!("unnamed-server-{index}"));
let transport = entry.get("transport").unwrap_or(entry);
if let Some(server) = json_utils::parse_mcp_server(transport) {
map.insert(name, server);
}
}
Ok(McpConfig { servers: map })
}
fn write_mcp_to_yaml(path: &Path, config: &McpConfig) -> Result<(), LorumError> {
let mut root = if path.exists() {
let contents = std::fs::read_to_string(path)?;
serde_yaml::from_str(&contents).map_err(|e| LorumError::ConfigParse {
format: "yaml".into(),
path: path.to_path_buf(),
source: Box::new(e),
})?
} else {
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
};
let servers_array: Vec<serde_yaml::Value> = config
.servers
.iter()
.map(|(name, server)| mcp_server_to_yaml_value(name, server))
.collect();
root.as_mapping_mut()
.ok_or_else(|| LorumError::Other {
message: format!("expected mapping at root of {}", path.display()),
})?
.insert(
serde_yaml::Value::String(YAML_MCP_FIELD.into()),
serde_yaml::Value::Sequence(servers_array),
);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
}
let formatted = serde_yaml::to_string(&root).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
std::fs::write(path, formatted).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
fn mcp_server_to_yaml_value(name: &str, server: &McpServer) -> serde_yaml::Value {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert(
serde_yaml::Value::String("name".into()),
serde_yaml::Value::String(name.into()),
);
mapping.insert(
serde_yaml::Value::String("command".into()),
serde_yaml::Value::String(server.command.clone()),
);
mapping.insert(
serde_yaml::Value::String("args".into()),
serde_yaml::Value::Sequence(
server
.args
.iter()
.map(|a| serde_yaml::Value::String(a.clone()))
.collect(),
),
);
if !server.env.is_empty() {
let env_mapping: serde_yaml::Mapping = server
.env
.iter()
.map(|(k, v)| {
(
serde_yaml::Value::String(k.clone()),
serde_yaml::Value::String(v.clone()),
)
})
.collect();
mapping.insert(
serde_yaml::Value::String("env".into()),
serde_yaml::Value::Mapping(env_mapping),
);
}
serde_yaml::Value::Mapping(mapping)
}
fn write_mcp_to_json(path: &Path, config: &McpConfig) -> Result<(), LorumError> {
let mut root = json_utils::read_existing_json(path)?;
let servers_array: Vec<serde_json::Value> = config
.servers
.iter()
.map(|(name, server)| {
let mut transport = serde_json::Map::new();
transport.insert("type".into(), serde_json::Value::String("stdio".into()));
transport.insert(
"command".into(),
serde_json::Value::String(server.command.clone()),
);
transport.insert(
"args".into(),
serde_json::Value::Array(
server
.args
.iter()
.map(|a| serde_json::Value::String(a.clone()))
.collect(),
),
);
if !server.env.is_empty() {
let env_obj: serde_json::Map<String, serde_json::Value> = server
.env
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
transport.insert("env".into(), serde_json::Value::Object(env_obj));
}
let mut entry = serde_json::Map::new();
entry.insert("name".into(), serde_json::Value::String(name.clone()));
entry.insert("transport".into(), serde_json::Value::Object(transport));
serde_json::Value::Object(entry)
})
.collect();
let experimental = root
.as_object_mut()
.ok_or_else(|| LorumError::Other {
message: format!("expected object at root of {}", path.display()),
})?
.entry(JSON_EXPERIMENTAL_FIELD)
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| LorumError::Other {
message: format!(
"expected object for '{}' at {}",
JSON_EXPERIMENTAL_FIELD,
path.display()
),
})?;
experimental.insert(
JSON_MCP_FIELD.into(),
serde_json::Value::Array(servers_array),
);
json_utils::write_json(path, &root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::test_utils::make_server;
use std::collections::BTreeMap;
use std::fs;
#[test]
fn read_mcp_from_valid_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
let yaml = r#"
mcpServers:
- name: test-server
command: npx
args:
- "-y"
- "some-pkg"
env:
KEY: value
otherField: true
"#;
fs::write(&path, yaml).unwrap();
let config = read_mcp_from_yaml(&path).unwrap();
assert_eq!(config.servers.len(), 1);
let server = &config.servers["test-server"];
assert_eq!(server.command, "npx");
assert_eq!(server.args, vec!["-y", "some-pkg"]);
assert_eq!(server.env.get("KEY").unwrap(), "value");
}
#[test]
fn read_mcp_from_valid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
let json = r#"{
"experimental": {
"modelContextProtocolServers": [
{
"name": "test-server",
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "some-pkg"],
"env": { "KEY": "value" }
}
}
]
},
"otherField": true
}"#;
fs::write(&path, json).unwrap();
let config = read_mcp_from_json(&path).unwrap();
assert_eq!(config.servers.len(), 1);
let server = &config.servers["test-server"];
assert_eq!(server.command, "npx");
assert_eq!(server.args, vec!["-y", "some-pkg"]);
assert_eq!(server.env.get("KEY").unwrap(), "value");
}
#[test]
fn read_mcp_empty_when_no_field_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "otherField: true\n").unwrap();
let config = read_mcp_from_yaml(&path).unwrap();
assert!(config.servers.is_empty());
}
#[test]
fn read_mcp_empty_when_no_field_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, r#"{"otherField": true}"#).unwrap();
let config = read_mcp_from_json(&path).unwrap();
assert!(config.servers.is_empty());
}
#[test]
fn read_mcp_generates_synthetic_name_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
let yaml = r#"
mcpServers:
- command: npx
args: ["-y", "some-pkg"]
"#;
fs::write(&path, yaml).unwrap();
let config = read_mcp_from_yaml(&path).unwrap();
assert_eq!(config.servers.len(), 1);
assert!(config.servers.contains_key("unnamed-server-0"));
}
#[test]
fn write_mcp_preserves_other_fields_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
let original = "otherField: true\nmcpServers: []\n";
fs::write(&path, original).unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("svr".into(), make_server("cmd", &["a"], &[("K", "V")]));
m
},
};
write_mcp_to_yaml(&path, &config).unwrap();
let result = fs::read_to_string(&path).unwrap();
let root: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
assert_eq!(root.get("otherField").and_then(|v| v.as_bool()), Some(true));
let servers = root
.get(YAML_MCP_FIELD)
.and_then(|v| v.as_sequence())
.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].get("name").and_then(|v| v.as_str()), Some("svr"));
assert_eq!(
servers[0].get("command").and_then(|v| v.as_str()),
Some("cmd")
);
}
#[test]
fn write_mcp_preserves_other_fields_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
let original =
r#"{"otherField": true, "experimental": {"modelContextProtocolServers": []}}"#;
fs::write(&path, original).unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("svr".into(), make_server("cmd", &["a"], &[("K", "V")]));
m
},
};
write_mcp_to_json(&path, &config).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(result["otherField"], true);
let servers = result[JSON_EXPERIMENTAL_FIELD][JSON_MCP_FIELD]
.as_array()
.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0]["name"], "svr");
assert_eq!(servers[0]["transport"]["command"], "cmd");
assert_eq!(servers[0]["transport"]["type"], "stdio");
}
#[test]
fn write_mcp_creates_file_when_missing_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".continue").join("config.yaml");
assert!(!path.exists());
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
write_mcp_to_yaml(&path, &config).unwrap();
assert!(path.exists());
let result = fs::read_to_string(&path).unwrap();
let root: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
let servers = root
.get(YAML_MCP_FIELD)
.and_then(|v| v.as_sequence())
.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].get("name").and_then(|v| v.as_str()), Some("s"));
assert_eq!(
servers[0].get("command").and_then(|v| v.as_str()),
Some("c")
);
}
#[test]
fn write_mcp_creates_file_when_missing_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".continue").join("config.json");
assert!(!path.exists());
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
write_mcp_to_json(&path, &config).unwrap();
assert!(path.exists());
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let servers = result[JSON_EXPERIMENTAL_FIELD][JSON_MCP_FIELD]
.as_array()
.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0]["name"], "s");
assert_eq!(servers[0]["transport"]["command"], "c");
assert_eq!(servers[0]["transport"]["type"], "stdio");
}
#[test]
fn roundtrip_yaml() {
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert(
"a".into(),
make_server("node", &["index.js"], &[("PORT", "3000")]),
);
m.insert("b".into(), make_server("python", &["main.py"], &[]));
m
},
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
write_mcp_to_yaml(&path, &config).unwrap();
let parsed = read_mcp_from_yaml(&path).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn roundtrip_json() {
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert(
"a".into(),
make_server("node", &["index.js"], &[("PORT", "3000")]),
);
m.insert("b".into(), make_server("python", &["main.py"], &[]));
m
},
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
write_mcp_to_json(&path, &config).unwrap();
let parsed = read_mcp_from_json(&path).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn adapter_name() {
let adapter = ContinueDevAdapter::new();
assert_eq!(ToolAdapter::name(&adapter), "continue");
}
#[test]
fn with_project_root_overrides_cwd() {
let dir = tempfile::tempdir().unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let paths = adapter.config_paths();
assert_eq!(paths.len(), 4);
assert_eq!(paths[0], dir.path().join(".continue").join("config.yaml"));
assert_eq!(paths[2], dir.path().join(".continue").join("config.json"));
}
#[test]
fn adapter_read_mcp_prefers_yaml_over_json() {
let dir = tempfile::tempdir().unwrap();
let continue_dir = dir.path().join(".continue");
fs::create_dir_all(&continue_dir).unwrap();
let yaml_path = continue_dir.join("config.yaml");
let yaml = r#"
mcpServers:
- name: yaml-server
command: npx
args: ["-y", "yaml-pkg"]
"#;
fs::write(&yaml_path, yaml).unwrap();
let json_path = continue_dir.join("config.json");
let json = r#"{
"experimental": {
"modelContextProtocolServers": [
{
"name": "json-server",
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "json-pkg"]
}
}
]
}
}"#;
fs::write(&json_path, json).unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let config = adapter.read_mcp().unwrap();
assert_eq!(config.servers.len(), 1);
assert!(config.servers.contains_key("yaml-server"));
}
#[test]
fn adapter_detect_format_defaults_to_project_yaml() {
let dir = tempfile::tempdir().unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let (path, format) = adapter.detect_format();
assert_eq!(path, dir.path().join(".continue").join("config.yaml"));
assert_eq!(format, ConfigFormat::Yaml);
}
#[test]
fn adapter_detect_format_finds_existing_json() {
let dir = tempfile::tempdir().unwrap();
let continue_dir = dir.path().join(".continue");
fs::create_dir_all(&continue_dir).unwrap();
let json_path = continue_dir.join("config.json");
fs::write(&json_path, "{}").unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let (path, format) = adapter.detect_format();
assert_eq!(path, json_path);
assert_eq!(format, ConfigFormat::Json);
}
#[test]
fn adapter_write_mcp_defaults_to_yaml_when_no_file_exists() {
let dir = tempfile::tempdir().unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
adapter.write_mcp(&config).unwrap();
let yaml_path = dir.path().join(".continue").join("config.yaml");
assert!(yaml_path.exists());
let parsed = read_mcp_from_yaml(&yaml_path).unwrap();
assert_eq!(parsed.servers.len(), 1);
assert!(parsed.servers.contains_key("s"));
}
#[test]
fn adapter_write_mcp_keeps_json_format_when_json_exists() {
let dir = tempfile::tempdir().unwrap();
let continue_dir = dir.path().join(".continue");
fs::create_dir_all(&continue_dir).unwrap();
let json_path = continue_dir.join("config.json");
fs::write(&json_path, "{}").unwrap();
let adapter = ContinueDevAdapter::with_project_root(dir.path().to_path_buf());
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
adapter.write_mcp(&config).unwrap();
assert!(json_path.exists());
let parsed = read_mcp_from_json(&json_path).unwrap();
assert_eq!(parsed.servers.len(), 1);
assert!(parsed.servers.contains_key("s"));
}
#[test]
fn read_mcp_from_yaml_errors_on_invalid_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "mcpServers: [unclosed").unwrap();
let result = read_mcp_from_yaml(&path);
assert!(matches!(result, Err(LorumError::ConfigParse { .. })));
}
#[test]
fn write_yaml_errors_when_root_not_mapping() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "[1, 2, 3]\n").unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_yaml(&path, &config);
assert!(matches!(result, Err(LorumError::Other { .. })));
if let Err(LorumError::Other { message }) = result {
assert!(message.contains("expected mapping at root"));
}
}
#[test]
fn write_yaml_errors_when_dir_creation_fails() {
let path = PathBuf::from("/dev/null/config.yaml");
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_yaml(&path, &config);
assert!(matches!(result, Err(LorumError::ConfigWrite { .. })));
}
#[test]
#[allow(clippy::permissions_set_readonly_false)]
fn write_yaml_errors_when_file_not_writable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "existing: true\n").unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&path, perms).unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_yaml(&path, &config);
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&path, perms);
assert!(matches!(result, Err(LorumError::ConfigWrite { .. })));
}
#[test]
fn write_json_errors_when_root_not_object() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, "[1, 2, 3]").unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_json(&path, &config);
assert!(matches!(result, Err(LorumError::Other { .. })));
if let Err(LorumError::Other { message }) = result {
assert!(message.contains("expected object at root"));
}
}
#[test]
fn write_json_errors_when_experimental_not_object() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, r#"{"experimental": 42}"#).unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_json(&path, &config);
assert!(matches!(result, Err(LorumError::Other { .. })));
if let Err(LorumError::Other { message }) = result {
assert!(message.contains("expected object for 'experimental'"));
}
}
#[test]
fn detect_format_fallback_when_no_project_root_and_no_files() {
let adapter = ContinueDevAdapter::new();
let (path, format) = adapter.detect_format();
assert!(path.to_string_lossy().ends_with(".continue/config.yaml"));
assert_eq!(format, ConfigFormat::Yaml);
}
#[test]
fn write_yaml_errors_on_invalid_existing_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "{{invalid").unwrap();
let config = McpConfig {
servers: {
let mut m = BTreeMap::new();
m.insert("s".into(), make_server("c", &[], &[]));
m
},
};
let result = write_mcp_to_yaml(&path, &config);
assert!(matches!(result, Err(LorumError::ConfigParse { .. })));
}
#[test]
#[cfg(unix)]
fn detect_format_fallback_when_current_dir_removed() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&subdir).unwrap();
fs::remove_dir(&subdir).unwrap();
let adapter = ContinueDevAdapter::new();
let (path, format) = adapter.detect_format();
assert_eq!(path, PathBuf::from(".continue/config.yaml"));
assert_eq!(format, ConfigFormat::Yaml);
let _ = std::env::set_current_dir(&original_dir);
}
}