1use std::collections::HashMap;
24
25use serde_json::Value;
26
27use crate::protocol::ToolDefinition;
28
29#[derive(Debug, Clone)]
31pub struct ToolMatch {
32 pub tool_name: String,
34 pub local_present: bool,
36 pub remote_present: bool,
38 pub schema_compatible: Option<bool>,
41 pub upstream_prefix: Option<String>,
44 pub schema_mismatch: Option<String>,
47}
48
49impl ToolMatch {
50 pub fn is_matched(&self) -> bool {
52 self.local_present && self.remote_present
53 }
54
55 pub fn is_routable_local(&self) -> bool {
57 self.is_matched() && self.schema_compatible.unwrap_or(false)
58 }
59
60 pub fn prefixed_remote_name(&self) -> Option<String> {
62 self.upstream_prefix
63 .as_ref()
64 .map(|p| format!("{}__{}", p, self.tool_name))
65 }
66}
67
68#[derive(Debug, Clone, Default)]
70pub struct MatchReport {
71 pub matches: HashMap<String, ToolMatch>,
72}
73
74impl MatchReport {
75 pub fn routable_locally(&self) -> Vec<&ToolMatch> {
77 self.matches
78 .values()
79 .filter(|m| m.is_routable_local())
80 .collect()
81 }
82
83 pub fn remote_only(&self) -> Vec<&ToolMatch> {
85 self.matches
86 .values()
87 .filter(|m| m.remote_present && !m.local_present)
88 .collect()
89 }
90
91 pub fn local_only(&self) -> Vec<&ToolMatch> {
93 self.matches
94 .values()
95 .filter(|m| m.local_present && !m.remote_present)
96 .collect()
97 }
98
99 pub fn incompatible_pairs(&self) -> Vec<&ToolMatch> {
101 self.matches
102 .values()
103 .filter(|m| m.is_matched() && m.schema_compatible == Some(false))
104 .collect()
105 }
106
107 pub fn get(&self, tool_name: &str) -> Option<&ToolMatch> {
108 self.matches.get(tool_name)
109 }
110
111 pub fn len(&self) -> usize {
112 self.matches.len()
113 }
114
115 pub fn is_empty(&self) -> bool {
116 self.matches.is_empty()
117 }
118}
119
120pub struct ToolCatalogue<'a> {
122 pub local: &'a [ToolDefinition],
124 pub upstream: Vec<(String, &'a [ToolDefinition])>,
127}
128
129pub fn build_report(catalogue: ToolCatalogue<'_>) -> MatchReport {
131 let mut matches: HashMap<String, ToolMatch> = HashMap::new();
132
133 for tool in catalogue.local {
134 matches.insert(
135 tool.name.clone(),
136 ToolMatch {
137 tool_name: tool.name.clone(),
138 local_present: true,
139 remote_present: false,
140 schema_compatible: None,
141 upstream_prefix: None,
142 schema_mismatch: None,
143 },
144 );
145 }
146
147 for (prefix, upstream_tools) in &catalogue.upstream {
148 for up_tool in *upstream_tools {
149 let entry = matches
150 .entry(up_tool.name.clone())
151 .or_insert_with(|| ToolMatch {
152 tool_name: up_tool.name.clone(),
153 local_present: false,
154 remote_present: false,
155 schema_compatible: None,
156 upstream_prefix: None,
157 schema_mismatch: None,
158 });
159
160 entry.remote_present = true;
161 if entry.upstream_prefix.is_none() {
162 entry.upstream_prefix = Some(prefix.clone());
163 }
164
165 if entry.local_present
166 && let Some(local_tool) = catalogue.local.iter().find(|t| t.name == up_tool.name)
167 {
168 let check = check_schema_compat(&local_tool.input_schema, &up_tool.input_schema);
169 entry.schema_compatible = Some(check.is_compatible);
170 entry.schema_mismatch = check.reason;
171 }
172 }
173 }
174
175 MatchReport { matches }
176}
177
178#[derive(Debug, Clone)]
179struct SchemaCheck {
180 is_compatible: bool,
181 reason: Option<String>,
182}
183
184fn check_schema_compat(local: &Value, remote: &Value) -> SchemaCheck {
198 let local_props = schema_properties(local);
199 let local_required = schema_required(local);
200 let remote_props = schema_properties(remote);
201 let remote_required = schema_required(remote);
202
203 for field in &remote_required {
204 if !local_props.contains_key(field) {
205 return SchemaCheck {
206 is_compatible: false,
207 reason: Some(format!(
208 "upstream requires `{}` which local schema does not declare",
209 field
210 )),
211 };
212 }
213 }
214
215 for field in &local_required {
216 if !remote_props.contains_key(field) && !remote_required.contains(field) {
217 return SchemaCheck {
218 is_compatible: true,
219 reason: Some(format!(
220 "local requires `{}` which upstream schema does not describe; local enforcement still applies",
221 field
222 )),
223 };
224 }
225 }
226
227 SchemaCheck {
228 is_compatible: true,
229 reason: None,
230 }
231}
232
233fn schema_properties(schema: &Value) -> HashMap<String, &Value> {
234 let Some(obj) = schema.as_object() else {
235 return HashMap::new();
236 };
237 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
238 return HashMap::new();
239 };
240 props.iter().map(|(k, v)| (k.clone(), v)).collect()
241}
242
243fn schema_required(schema: &Value) -> Vec<String> {
244 schema
245 .get("required")
246 .and_then(|v| v.as_array())
247 .map(|a| {
248 a.iter()
249 .filter_map(|v| v.as_str().map(String::from))
250 .collect()
251 })
252 .unwrap_or_default()
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 fn tool(name: &str, schema: Value) -> ToolDefinition {
261 ToolDefinition {
262 name: name.to_string(),
263 description: format!("tool {}", name),
264 input_schema: schema,
265 category: None,
266 }
267 }
268
269 fn empty_schema() -> Value {
270 json!({"type": "object", "properties": {}, "required": []})
271 }
272
273 #[test]
274 fn test_build_report_all_matched_same_schema() {
275 let local = vec![
276 tool("get_issues", empty_schema()),
277 tool("get_merge_requests", empty_schema()),
278 ];
279 let remote = vec![
280 tool("get_issues", empty_schema()),
281 tool("get_merge_requests", empty_schema()),
282 ];
283
284 let report = build_report(ToolCatalogue {
285 local: &local,
286 upstream: vec![("cloud".to_string(), &remote)],
287 });
288
289 assert_eq!(report.len(), 2);
290 let m = report.get("get_issues").unwrap();
291 assert!(m.is_matched());
292 assert_eq!(m.schema_compatible, Some(true));
293 assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
294 assert_eq!(
295 m.prefixed_remote_name().as_deref(),
296 Some("cloud__get_issues")
297 );
298 }
299
300 #[test]
301 fn test_build_report_local_only() {
302 let local = vec![tool("list_contexts", empty_schema())];
303 let report = build_report(ToolCatalogue {
304 local: &local,
305 upstream: vec![("cloud".to_string(), &[])],
306 });
307
308 let m = report.get("list_contexts").unwrap();
309 assert!(m.local_present);
310 assert!(!m.remote_present);
311 assert!(!m.is_matched());
312 }
313
314 #[test]
315 fn test_build_report_remote_only() {
316 let remote = vec![tool("cloud_specific_tool", empty_schema())];
317 let report = build_report(ToolCatalogue {
318 local: &[],
319 upstream: vec![("cloud".to_string(), &remote)],
320 });
321
322 let m = report.get("cloud_specific_tool").unwrap();
323 assert!(m.remote_present);
324 assert!(!m.local_present);
325 assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
326 }
327
328 #[test]
329 fn test_schema_compat_missing_required_field_is_incompatible() {
330 let local = vec![tool(
331 "get_issue",
332 json!({
333 "type": "object",
334 "properties": { "key": {"type": "string"} },
335 "required": ["key"]
336 }),
337 )];
338 let remote = vec![tool(
339 "get_issue",
340 json!({
341 "type": "object",
342 "properties": {
343 "key": {"type": "string"},
344 "workspace_id": {"type": "string"}
345 },
346 "required": ["key", "workspace_id"]
347 }),
348 )];
349
350 let report = build_report(ToolCatalogue {
351 local: &local,
352 upstream: vec![("cloud".to_string(), &remote)],
353 });
354
355 let m = report.get("get_issue").unwrap();
356 assert!(m.is_matched());
357 assert_eq!(m.schema_compatible, Some(false));
358 assert!(m.schema_mismatch.is_some());
359 assert!(!m.is_routable_local());
360 }
361
362 #[test]
363 fn test_schema_compat_extra_local_required_is_advisory_but_compatible() {
364 let local = vec![tool(
365 "get_issue",
366 json!({
367 "type": "object",
368 "properties": {
369 "key": {"type": "string"},
370 "workspace_id": {"type": "string"}
371 },
372 "required": ["key", "workspace_id"]
373 }),
374 )];
375 let remote = vec![tool(
376 "get_issue",
377 json!({
378 "type": "object",
379 "properties": { "key": {"type": "string"} },
380 "required": ["key"]
381 }),
382 )];
383
384 let report = build_report(ToolCatalogue {
385 local: &local,
386 upstream: vec![("cloud".to_string(), &remote)],
387 });
388
389 let m = report.get("get_issue").unwrap();
390 assert_eq!(m.schema_compatible, Some(true));
391 assert!(m.schema_mismatch.is_some());
392 assert!(m.is_routable_local());
393 }
394
395 #[test]
396 fn test_report_classification_helpers() {
397 let local = vec![
398 tool("local_only", empty_schema()),
399 tool("both_matched", empty_schema()),
400 ];
401 let remote = vec![
402 tool("remote_only", empty_schema()),
403 tool("both_matched", empty_schema()),
404 ];
405
406 let report = build_report(ToolCatalogue {
407 local: &local,
408 upstream: vec![("up".to_string(), &remote)],
409 });
410
411 let local_only: Vec<&str> = report
412 .local_only()
413 .iter()
414 .map(|m| m.tool_name.as_str())
415 .collect();
416 let remote_only: Vec<&str> = report
417 .remote_only()
418 .iter()
419 .map(|m| m.tool_name.as_str())
420 .collect();
421 let routable: Vec<&str> = report
422 .routable_locally()
423 .iter()
424 .map(|m| m.tool_name.as_str())
425 .collect();
426
427 assert_eq!(local_only, vec!["local_only"]);
428 assert_eq!(remote_only, vec!["remote_only"]);
429 assert_eq!(routable, vec!["both_matched"]);
430 }
431
432 #[test]
433 fn test_first_upstream_wins_prefix_when_multiple_advertise_same_tool() {
434 let a = vec![tool("shared", empty_schema())];
435 let b = vec![tool("shared", empty_schema())];
436
437 let report = build_report(ToolCatalogue {
438 local: &[],
439 upstream: vec![("cloudA".to_string(), &a), ("cloudB".to_string(), &b)],
440 });
441
442 assert_eq!(
443 report.get("shared").unwrap().upstream_prefix.as_deref(),
444 Some("cloudA")
445 );
446 }
447}