use std::collections::HashMap;
use serde_json::Value;
use crate::protocol::ToolDefinition;
#[derive(Debug, Clone)]
pub struct ToolMatch {
pub tool_name: String,
pub local_present: bool,
pub remote_present: bool,
pub schema_compatible: Option<bool>,
pub upstream_prefix: Option<String>,
pub schema_mismatch: Option<String>,
}
impl ToolMatch {
pub fn is_matched(&self) -> bool {
self.local_present && self.remote_present
}
pub fn is_routable_local(&self) -> bool {
self.is_matched() && self.schema_compatible.unwrap_or(false)
}
pub fn prefixed_remote_name(&self) -> Option<String> {
self.upstream_prefix
.as_ref()
.map(|p| format!("{}__{}", p, self.tool_name))
}
}
#[derive(Debug, Clone, Default)]
pub struct MatchReport {
pub matches: HashMap<String, ToolMatch>,
}
impl MatchReport {
pub fn routable_locally(&self) -> Vec<&ToolMatch> {
self.matches
.values()
.filter(|m| m.is_routable_local())
.collect()
}
pub fn remote_only(&self) -> Vec<&ToolMatch> {
self.matches
.values()
.filter(|m| m.remote_present && !m.local_present)
.collect()
}
pub fn local_only(&self) -> Vec<&ToolMatch> {
self.matches
.values()
.filter(|m| m.local_present && !m.remote_present)
.collect()
}
pub fn incompatible_pairs(&self) -> Vec<&ToolMatch> {
self.matches
.values()
.filter(|m| m.is_matched() && m.schema_compatible == Some(false))
.collect()
}
pub fn get(&self, tool_name: &str) -> Option<&ToolMatch> {
self.matches.get(tool_name)
}
pub fn len(&self) -> usize {
self.matches.len()
}
pub fn is_empty(&self) -> bool {
self.matches.is_empty()
}
}
pub struct ToolCatalogue<'a> {
pub local: &'a [ToolDefinition],
pub upstream: Vec<(String, &'a [ToolDefinition])>,
}
pub fn build_report(catalogue: ToolCatalogue<'_>) -> MatchReport {
let mut matches: HashMap<String, ToolMatch> = HashMap::new();
for tool in catalogue.local {
matches.insert(
tool.name.clone(),
ToolMatch {
tool_name: tool.name.clone(),
local_present: true,
remote_present: false,
schema_compatible: None,
upstream_prefix: None,
schema_mismatch: None,
},
);
}
for (prefix, upstream_tools) in &catalogue.upstream {
for up_tool in *upstream_tools {
let entry = matches
.entry(up_tool.name.clone())
.or_insert_with(|| ToolMatch {
tool_name: up_tool.name.clone(),
local_present: false,
remote_present: false,
schema_compatible: None,
upstream_prefix: None,
schema_mismatch: None,
});
entry.remote_present = true;
if entry.upstream_prefix.is_none() {
entry.upstream_prefix = Some(prefix.clone());
}
if entry.local_present
&& let Some(local_tool) = catalogue.local.iter().find(|t| t.name == up_tool.name)
{
let check = check_schema_compat(&local_tool.input_schema, &up_tool.input_schema);
entry.schema_compatible = Some(check.is_compatible);
entry.schema_mismatch = check.reason;
}
}
}
MatchReport { matches }
}
#[derive(Debug, Clone)]
struct SchemaCheck {
is_compatible: bool,
reason: Option<String>,
}
fn check_schema_compat(local: &Value, remote: &Value) -> SchemaCheck {
let local_props = schema_properties(local);
let local_required = schema_required(local);
let remote_props = schema_properties(remote);
let remote_required = schema_required(remote);
for field in &remote_required {
if !local_props.contains_key(field) {
return SchemaCheck {
is_compatible: false,
reason: Some(format!(
"upstream requires `{}` which local schema does not declare",
field
)),
};
}
}
for field in &local_required {
if !remote_props.contains_key(field) && !remote_required.contains(field) {
return SchemaCheck {
is_compatible: true,
reason: Some(format!(
"local requires `{}` which upstream schema does not describe; local enforcement still applies",
field
)),
};
}
}
SchemaCheck {
is_compatible: true,
reason: None,
}
}
fn schema_properties(schema: &Value) -> HashMap<String, &Value> {
let Some(obj) = schema.as_object() else {
return HashMap::new();
};
let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
return HashMap::new();
};
props.iter().map(|(k, v)| (k.clone(), v)).collect()
}
fn schema_required(schema: &Value) -> Vec<String> {
schema
.get("required")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tool(name: &str, schema: Value) -> ToolDefinition {
ToolDefinition {
name: name.to_string(),
description: format!("tool {}", name),
input_schema: schema,
category: None,
}
}
fn empty_schema() -> Value {
json!({"type": "object", "properties": {}, "required": []})
}
#[test]
fn test_build_report_all_matched_same_schema() {
let local = vec![
tool("get_issues", empty_schema()),
tool("get_merge_requests", empty_schema()),
];
let remote = vec![
tool("get_issues", empty_schema()),
tool("get_merge_requests", empty_schema()),
];
let report = build_report(ToolCatalogue {
local: &local,
upstream: vec![("cloud".to_string(), &remote)],
});
assert_eq!(report.len(), 2);
let m = report.get("get_issues").unwrap();
assert!(m.is_matched());
assert_eq!(m.schema_compatible, Some(true));
assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
assert_eq!(
m.prefixed_remote_name().as_deref(),
Some("cloud__get_issues")
);
}
#[test]
fn test_build_report_local_only() {
let local = vec![tool("list_contexts", empty_schema())];
let report = build_report(ToolCatalogue {
local: &local,
upstream: vec![("cloud".to_string(), &[])],
});
let m = report.get("list_contexts").unwrap();
assert!(m.local_present);
assert!(!m.remote_present);
assert!(!m.is_matched());
}
#[test]
fn test_build_report_remote_only() {
let remote = vec![tool("cloud_specific_tool", empty_schema())];
let report = build_report(ToolCatalogue {
local: &[],
upstream: vec![("cloud".to_string(), &remote)],
});
let m = report.get("cloud_specific_tool").unwrap();
assert!(m.remote_present);
assert!(!m.local_present);
assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
}
#[test]
fn test_schema_compat_missing_required_field_is_incompatible() {
let local = vec![tool(
"get_issue",
json!({
"type": "object",
"properties": { "key": {"type": "string"} },
"required": ["key"]
}),
)];
let remote = vec![tool(
"get_issue",
json!({
"type": "object",
"properties": {
"key": {"type": "string"},
"workspace_id": {"type": "string"}
},
"required": ["key", "workspace_id"]
}),
)];
let report = build_report(ToolCatalogue {
local: &local,
upstream: vec![("cloud".to_string(), &remote)],
});
let m = report.get("get_issue").unwrap();
assert!(m.is_matched());
assert_eq!(m.schema_compatible, Some(false));
assert!(m.schema_mismatch.is_some());
assert!(!m.is_routable_local());
}
#[test]
fn test_schema_compat_extra_local_required_is_advisory_but_compatible() {
let local = vec![tool(
"get_issue",
json!({
"type": "object",
"properties": {
"key": {"type": "string"},
"workspace_id": {"type": "string"}
},
"required": ["key", "workspace_id"]
}),
)];
let remote = vec![tool(
"get_issue",
json!({
"type": "object",
"properties": { "key": {"type": "string"} },
"required": ["key"]
}),
)];
let report = build_report(ToolCatalogue {
local: &local,
upstream: vec![("cloud".to_string(), &remote)],
});
let m = report.get("get_issue").unwrap();
assert_eq!(m.schema_compatible, Some(true));
assert!(m.schema_mismatch.is_some());
assert!(m.is_routable_local());
}
#[test]
fn test_report_classification_helpers() {
let local = vec![
tool("local_only", empty_schema()),
tool("both_matched", empty_schema()),
];
let remote = vec![
tool("remote_only", empty_schema()),
tool("both_matched", empty_schema()),
];
let report = build_report(ToolCatalogue {
local: &local,
upstream: vec![("up".to_string(), &remote)],
});
let local_only: Vec<&str> = report
.local_only()
.iter()
.map(|m| m.tool_name.as_str())
.collect();
let remote_only: Vec<&str> = report
.remote_only()
.iter()
.map(|m| m.tool_name.as_str())
.collect();
let routable: Vec<&str> = report
.routable_locally()
.iter()
.map(|m| m.tool_name.as_str())
.collect();
assert_eq!(local_only, vec!["local_only"]);
assert_eq!(remote_only, vec!["remote_only"]);
assert_eq!(routable, vec!["both_matched"]);
}
#[test]
fn test_first_upstream_wins_prefix_when_multiple_advertise_same_tool() {
let a = vec![tool("shared", empty_schema())];
let b = vec![tool("shared", empty_schema())];
let report = build_report(ToolCatalogue {
local: &[],
upstream: vec![("cloudA".to_string(), &a), ("cloudB".to_string(), &b)],
});
assert_eq!(
report.get("shared").unwrap().upstream_prefix.as_deref(),
Some("cloudA")
);
}
}