mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 23:01:52 +08:00
Unblock typed runtime integration config primitives
Add typed runtime-facing MCP and OAuth configuration models on top of the existing merged settings loader so later parity work can consume validated structures instead of ad hoc JSON traversal. This keeps the first slice bounded to parsing, precedence, exports, and tests. While validating the slice under the repo's required clippy gate, I also fixed a handful of pre-existing clippy failures in runtime file operations so the requested verification command can pass for this commit. Constraint: Must keep scope to parity-unblocking primitives, not full MCP or OAuth flow execution Constraint: cargo clippy --all-targets is a required verification gate for this repo Rejected: Add a new integrations crate first | too much boundary churn for the first landing slice Rejected: Leave existing clippy failures untouched | would block the required verification command for this commit Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep future MCP/OAuth additions layered on these typed config surfaces before introducing transport orchestration Tested: cargo fmt --all; cargo test -p runtime; cargo clippy -p runtime --all-targets -- -D warnings Not-tested: workspace-wide clippy/test beyond the runtime crate; live MCP or OAuth network flows
This commit is contained in:
@@ -24,6 +24,95 @@ pub struct ConfigEntry {
|
||||
pub struct RuntimeConfig {
|
||||
merged: BTreeMap<String, JsonValue>,
|
||||
loaded_entries: Vec<ConfigEntry>,
|
||||
feature_config: RuntimeFeatureConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection,
|
||||
oauth: Option<OAuthConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct McpConfigCollection {
|
||||
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopedMcpServerConfig {
|
||||
pub scope: ConfigSource,
|
||||
pub config: McpServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpTransport {
|
||||
Stdio,
|
||||
Sse,
|
||||
Http,
|
||||
Ws,
|
||||
Sdk,
|
||||
ClaudeAiProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpServerConfig {
|
||||
Stdio(McpStdioServerConfig),
|
||||
Sse(McpRemoteServerConfig),
|
||||
Http(McpRemoteServerConfig),
|
||||
Ws(McpWebSocketServerConfig),
|
||||
Sdk(McpSdkServerConfig),
|
||||
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpStdioServerConfig {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpRemoteServerConfig {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub headers_helper: Option<String>,
|
||||
pub oauth: Option<McpOAuthConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpWebSocketServerConfig {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub headers_helper: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpSdkServerConfig {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClaudeAiProxyServerConfig {
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpOAuthConfig {
|
||||
pub client_id: Option<String>,
|
||||
pub callback_port: Option<u16>,
|
||||
pub auth_server_metadata_url: Option<String>,
|
||||
pub xaa: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OAuthConfig {
|
||||
pub client_id: String,
|
||||
pub authorize_url: String,
|
||||
pub token_url: String,
|
||||
pub callback_port: Option<u16>,
|
||||
pub manual_redirect_url: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -95,18 +184,31 @@ impl ConfigLoader {
|
||||
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
let Some(value) = read_optional_json_object(&entry.path)? else {
|
||||
continue;
|
||||
};
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &value);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
oauth: parse_optional_oauth_config(
|
||||
&JsonValue::Object(merged.clone()),
|
||||
"merged settings.oauth",
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
merged,
|
||||
loaded_entries,
|
||||
feature_config,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,6 +219,7 @@ impl RuntimeConfig {
|
||||
Self {
|
||||
merged: BTreeMap::new(),
|
||||
loaded_entries: Vec::new(),
|
||||
feature_config: RuntimeFeatureConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +242,66 @@ impl RuntimeConfig {
|
||||
pub fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.merged.clone())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn feature_config(&self) -> &RuntimeFeatureConfig {
|
||||
&self.feature_config
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mcp(&self) -> &McpConfigCollection {
|
||||
&self.feature_config.mcp
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.feature_config.oauth.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
#[must_use]
|
||||
pub fn mcp(&self) -> &McpConfigCollection {
|
||||
&self.mcp
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.oauth.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfigCollection {
|
||||
#[must_use]
|
||||
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
||||
&self.servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
||||
self.servers.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopedMcpServerConfig {
|
||||
#[must_use]
|
||||
pub fn transport(&self) -> McpTransport {
|
||||
self.config.transport()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
#[must_use]
|
||||
pub fn transport(&self) -> McpTransport {
|
||||
match self {
|
||||
Self::Stdio(_) => McpTransport::Stdio,
|
||||
Self::Sse(_) => McpTransport::Sse,
|
||||
Self::Http(_) => McpTransport::Http,
|
||||
Self::Ws(_) => McpTransport::Ws,
|
||||
Self::Sdk(_) => McpTransport::Sdk,
|
||||
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_optional_json_object(
|
||||
@@ -165,6 +328,253 @@ fn read_optional_json_object(
|
||||
Ok(Some(object.clone()))
|
||||
}
|
||||
|
||||
fn merge_mcp_servers(
|
||||
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
||||
source: ConfigSource,
|
||||
root: &BTreeMap<String, JsonValue>,
|
||||
path: &Path,
|
||||
) -> Result<(), ConfigError> {
|
||||
let Some(mcp_servers) = root.get("mcpServers") else {
|
||||
return Ok(());
|
||||
};
|
||||
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
||||
for (name, value) in servers {
|
||||
let parsed = parse_mcp_server_config(
|
||||
name,
|
||||
value,
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?;
|
||||
target.insert(
|
||||
name.clone(),
|
||||
ScopedMcpServerConfig {
|
||||
scope: source,
|
||||
config: parsed,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_oauth_config(
|
||||
root: &JsonValue,
|
||||
context: &str,
|
||||
) -> Result<Option<OAuthConfig>, ConfigError> {
|
||||
let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let object = expect_object(oauth_value, context)?;
|
||||
let client_id = expect_string(object, "clientId", context)?.to_string();
|
||||
let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
|
||||
let token_url = expect_string(object, "tokenUrl", context)?.to_string();
|
||||
let callback_port = optional_u16(object, "callbackPort", context)?;
|
||||
let manual_redirect_url =
|
||||
optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
|
||||
let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
|
||||
Ok(Some(OAuthConfig {
|
||||
client_id,
|
||||
authorize_url,
|
||||
token_url,
|
||||
callback_port,
|
||||
manual_redirect_url,
|
||||
scopes,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_mcp_server_config(
|
||||
server_name: &str,
|
||||
value: &JsonValue,
|
||||
context: &str,
|
||||
) -> Result<McpServerConfig, ConfigError> {
|
||||
let object = expect_object(value, context)?;
|
||||
let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
|
||||
match server_type {
|
||||
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: expect_string(object, "command", context)?.to_string(),
|
||||
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
||||
})),
|
||||
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
||||
object, context,
|
||||
)?)),
|
||||
"http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
|
||||
object, context,
|
||||
)?)),
|
||||
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
||||
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
||||
})),
|
||||
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
||||
name: expect_string(object, "name", context)?.to_string(),
|
||||
})),
|
||||
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
|
||||
McpClaudeAiProxyServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
id: expect_string(object, "id", context)?.to_string(),
|
||||
},
|
||||
)),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported MCP server type for {server_name}: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mcp_remote_server_config(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<McpRemoteServerConfig, ConfigError> {
|
||||
Ok(McpRemoteServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
||||
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
||||
oauth: parse_optional_mcp_oauth_config(object, context)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_mcp_oauth_config(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<Option<McpOAuthConfig>, ConfigError> {
|
||||
let Some(value) = object.get("oauth") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let oauth = expect_object(value, &format!("{context}.oauth"))?;
|
||||
Ok(Some(McpOAuthConfig {
|
||||
client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
|
||||
callback_port: optional_u16(oauth, "callbackPort", context)?,
|
||||
auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
|
||||
.map(str::to_string),
|
||||
xaa: optional_bool(oauth, "xaa", context)?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn expect_object<'a>(
|
||||
value: &'a JsonValue,
|
||||
context: &str,
|
||||
) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
||||
}
|
||||
|
||||
fn expect_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a str, ConfigError> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
|
||||
}
|
||||
|
||||
fn optional_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<&'a str>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.map(Some)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_bool(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<bool>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.map(Some)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_u16(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<u16>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(number) = value.as_i64() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an integer"
|
||||
)));
|
||||
};
|
||||
let number = u16::try_from(number).map_err(|_| {
|
||||
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
||||
})?;
|
||||
Ok(Some(number))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string_array(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<Vec<String>>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(array) = value.as_array() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an array"
|
||||
)));
|
||||
};
|
||||
array
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key} must contain only strings"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string_map(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(map) = value.as_object() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an object"
|
||||
)));
|
||||
};
|
||||
map.iter()
|
||||
.map(|(entry_key, entry_value)| {
|
||||
entry_value
|
||||
.as_str()
|
||||
.map(|text| (entry_key.clone(), text.to_string()))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key} must contain only string values"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn deep_merge_objects(
|
||||
target: &mut BTreeMap<String, JsonValue>,
|
||||
source: &BTreeMap<String, JsonValue>,
|
||||
@@ -183,7 +593,9 @@ fn deep_merge_objects(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConfigLoader, ConfigSource, CLAUDE_CODE_SETTINGS_SCHEMA_NAME};
|
||||
use super::{
|
||||
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -266,4 +678,118 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_typed_mcp_and_oauth_config() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"stdio-server": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server"],
|
||||
"env": {"TOKEN": "secret"}
|
||||
},
|
||||
"remote-server": {
|
||||
"type": "http",
|
||||
"url": "https://example.test/mcp",
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"headersHelper": "helper.sh",
|
||||
"oauth": {
|
||||
"clientId": "mcp-client",
|
||||
"callbackPort": 7777,
|
||||
"authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
|
||||
"xaa": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"clientId": "runtime-client",
|
||||
"authorizeUrl": "https://console.test/oauth/authorize",
|
||||
"tokenUrl": "https://console.test/oauth/token",
|
||||
"callbackPort": 54545,
|
||||
"manualRedirectUrl": "https://console.test/oauth/callback",
|
||||
"scopes": ["org:read", "user:write"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"remote-server": {
|
||||
"type": "ws",
|
||||
"url": "wss://override.test/mcp",
|
||||
"headers": {"X-Env": "local"}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
let stdio_server = loaded
|
||||
.mcp()
|
||||
.get("stdio-server")
|
||||
.expect("stdio server should exist");
|
||||
assert_eq!(stdio_server.scope, ConfigSource::User);
|
||||
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
||||
|
||||
let remote_server = loaded
|
||||
.mcp()
|
||||
.get("remote-server")
|
||||
.expect("remote server should exist");
|
||||
assert_eq!(remote_server.scope, ConfigSource::Local);
|
||||
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
||||
match &remote_server.config {
|
||||
McpServerConfig::Ws(config) => {
|
||||
assert_eq!(config.url, "wss://override.test/mcp");
|
||||
assert_eq!(
|
||||
config.headers.get("X-Env").map(String::as_str),
|
||||
Some("local")
|
||||
);
|
||||
}
|
||||
other => panic!("expected ws config, got {other:?}"),
|
||||
}
|
||||
|
||||
let oauth = loaded.oauth().expect("oauth config should exist");
|
||||
assert_eq!(oauth.client_id, "runtime-client");
|
||||
assert_eq!(oauth.callback_port, Some(54_545));
|
||||
assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_server_shapes() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
|
||||
)
|
||||
.expect("write broken settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("mcpServers.broken: missing string field url"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ pub fn read_file(
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit
|
||||
.map(|limit| start_index.saturating_add(limit).min(lines.len()))
|
||||
.unwrap_or(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = fs::read_to_string(&file_path) else {
|
||||
let Ok(file_content) = fs::read_to_string(&file_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if output_mode == "count" {
|
||||
let count = regex.find_iter(&content).count();
|
||||
let count = regex.find_iter(&file_content).count();
|
||||
if count > 0 {
|
||||
filenames.push(file_path.to_string_lossy().into_owned());
|
||||
total_matches += count;
|
||||
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let lines: Vec<&str> = file_content.lines().collect();
|
||||
let mut matched_lines = Vec::new();
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
@@ -327,13 +327,13 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
for index in matched_lines {
|
||||
let start = index.saturating_sub(input.before.unwrap_or(context));
|
||||
let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
|
||||
for current in start..end {
|
||||
for (current, line_content) in lines.iter().enumerate().take(end).skip(start) {
|
||||
let prefix = if input.line_numbers.unwrap_or(true) {
|
||||
format!("{}:{}:", file_path.to_string_lossy(), current + 1)
|
||||
} else {
|
||||
format!("{}:", file_path.to_string_lossy())
|
||||
};
|
||||
content_lines.push(format!("{prefix}{}", lines[current]));
|
||||
content_lines.push(format!("{prefix}{line_content}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
|
||||
let (filenames, applied_limit, applied_offset) =
|
||||
apply_limit(filenames, input.head_limit, input.offset);
|
||||
let content = if output_mode == "content" {
|
||||
let rendered_content = if output_mode == "content" {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
||||
return Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
@@ -361,7 +361,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
mode: Some(output_mode.clone()),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
content,
|
||||
content: rendered_content,
|
||||
num_lines: None,
|
||||
num_matches: (output_mode == "count").then_some(total_matches),
|
||||
applied_limit,
|
||||
@@ -376,8 +376,7 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in WalkDir::new(base_path) {
|
||||
let entry =
|
||||
entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
|
||||
let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
if entry.file_type().is_file() {
|
||||
files.push(entry.path().to_path_buf());
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, RuntimeConfig,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
|
||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use conversation::{
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||
|
||||
Reference in New Issue
Block a user