1use anyhow::Result;
7use serde::Serialize;
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use crate::mcp::Tool;
11use crate::snapshot::ToolInfo;
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
15pub enum ChangeClass {
16 #[serde(rename = "BREAKING")]
17 Breaking,
18 #[serde(rename = "NON-BREAKING")]
19 NonBreaking,
20 #[serde(rename = "ADDITIVE")]
21 Additive,
22}
23
24impl std::fmt::Display for ChangeClass {
25 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
26 match self {
27 ChangeClass::Breaking => write!(f, "BREAKING"),
28 ChangeClass::NonBreaking => write!(f, "NON-BREAKING"),
29 ChangeClass::Additive => write!(f, "ADDITIVE"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct ToolChange {
36 pub tool_name: String,
37 pub change_type: String,
38 pub class: ChangeClass,
39 pub details: String,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct DiffReport {
44 pub breaking_changes: Vec<ToolChange>,
45 pub non_breaking_changes: Vec<ToolChange>,
46 pub additive_changes: Vec<ToolChange>,
47 pub overall_verdict: ChangeClass,
48}
49
50pub fn diff_tools(
51 baseline_tools: Vec<ToolInfo>,
52 candidate_tools: Vec<Tool>,
53) -> Result<DiffReport> {
54 let mut breaking = Vec::new();
55 let mut non_breaking = Vec::new();
56 let mut additive = Vec::new();
57
58 let baseline_map: HashMap<String, ToolInfo> = baseline_tools
60 .into_iter()
61 .map(|t| (t.name.clone(), t))
62 .collect();
63
64 let candidate_map: HashMap<String, Tool> = candidate_tools
65 .into_iter()
66 .map(|t| (t.name.clone(), t))
67 .collect();
68
69 for (name, _) in &baseline_map {
71 if !candidate_map.contains_key(name) {
72 breaking.push(ToolChange {
73 tool_name: name.clone(),
74 change_type: "Tool removed".to_string(),
75 class: ChangeClass::Breaking,
76 details: format!("Tool '{}' was removed", name),
77 });
78 }
79 }
80
81 for (name, _) in &candidate_map {
83 if !baseline_map.contains_key(name) {
84 additive.push(ToolChange {
85 tool_name: name.clone(),
86 change_type: "Tool added".to_string(),
87 class: ChangeClass::Additive,
88 details: format!("Tool '{}' was added", name),
89 });
90 }
91 }
92
93 for (name, baseline_tool) in &baseline_map {
95 if let Some(candidate_tool) = candidate_map.get(name) {
96 let changes = diff_schemas(name, baseline_tool, candidate_tool);
97 for change in changes {
98 match change.class {
99 ChangeClass::Breaking => breaking.push(change),
100 ChangeClass::NonBreaking => non_breaking.push(change),
101 ChangeClass::Additive => additive.push(change),
102 }
103 }
104 }
105 }
106
107 let overall_verdict = if !breaking.is_empty() {
109 ChangeClass::Breaking
110 } else if !non_breaking.is_empty() || !additive.is_empty() {
111 ChangeClass::NonBreaking
112 } else {
113 ChangeClass::Additive
114 };
115
116 Ok(DiffReport {
117 breaking_changes: breaking,
118 non_breaking_changes: non_breaking,
119 additive_changes: additive,
120 overall_verdict,
121 })
122}
123
124fn diff_schemas(tool_name: &str, baseline: &ToolInfo, candidate: &Tool) -> Vec<ToolChange> {
125 let mut changes = Vec::new();
126
127 let baseline_schema = baseline.input_schema.as_ref();
129 let candidate_schema = candidate.input_schema.as_ref();
130
131 if baseline.description != candidate.description {
133 changes.push(ToolChange {
134 tool_name: tool_name.to_string(),
135 change_type: "Description changed".to_string(),
136 class: ChangeClass::NonBreaking,
137 details: format!(
138 "Description changed from '{}' to '{}'",
139 baseline.description, candidate.description
140 ),
141 });
142 }
143
144 if let (Some(baseline_sch), Some(candidate_sch)) = (baseline_schema, candidate_schema) {
146 let param_changes = diff_input_params(tool_name, baseline_sch, candidate_sch);
147 changes.extend(param_changes);
148 }
149
150 changes
151}
152
153fn diff_input_params(tool_name: &str, baseline: &Value, candidate: &Value) -> Vec<ToolChange> {
154 let mut changes = Vec::new();
155
156 let baseline_props = baseline
157 .get("properties")
158 .and_then(|p| p.as_object())
159 .map(|p| p.clone())
160 .unwrap_or_default();
161
162 let candidate_props = candidate
163 .get("properties")
164 .and_then(|p| p.as_object())
165 .map(|p| p.clone())
166 .unwrap_or_default();
167
168 let baseline_required = baseline
169 .get("required")
170 .and_then(|r| r.as_array())
171 .map(|r| {
172 r.iter()
173 .filter_map(|v| v.as_str().map(|s| s.to_string()))
174 .collect::<Vec<_>>()
175 })
176 .unwrap_or_default();
177
178 let candidate_required = candidate
179 .get("required")
180 .and_then(|r| r.as_array())
181 .map(|r| {
182 r.iter()
183 .filter_map(|v| v.as_str().map(|s| s.to_string()))
184 .collect::<Vec<_>>()
185 })
186 .unwrap_or_default();
187
188 for (param_name, _baseline_param) in &baseline_props {
190 if !candidate_props.contains_key(param_name) {
191 let was_required = baseline_required.contains(param_name);
192 changes.push(ToolChange {
193 tool_name: tool_name.to_string(),
194 change_type: "Parameter removed".to_string(),
195 class: ChangeClass::Breaking,
196 details: format!(
197 "Parameter '{}' was removed (was required: {})",
198 param_name, was_required
199 ),
200 });
201 }
202 }
203
204 for (param_name, _) in &candidate_props {
206 if !baseline_props.contains_key(param_name) {
207 let is_required = candidate_required.contains(param_name);
208 let class = if is_required {
209 ChangeClass::Breaking
210 } else {
211 ChangeClass::Additive
212 };
213
214 changes.push(ToolChange {
215 tool_name: tool_name.to_string(),
216 change_type: "Parameter added".to_string(),
217 class,
218 details: format!(
219 "Parameter '{}' was added (required: {})",
220 param_name, is_required
221 ),
222 });
223 }
224 }
225
226 for (param_name, baseline_param) in &baseline_props {
228 if let Some(candidate_param) = candidate_props.get(param_name) {
229 let baseline_type = baseline_param.get("type").and_then(|t| t.as_str());
230 let candidate_type = candidate_param.get("type").and_then(|t| t.as_str());
231
232 if baseline_type != candidate_type {
233 changes.push(ToolChange {
234 tool_name: tool_name.to_string(),
235 change_type: "Parameter type changed".to_string(),
236 class: ChangeClass::Breaking,
237 details: format!(
238 "Parameter '{}' type changed from '{}' to '{}'",
239 param_name,
240 baseline_type.unwrap_or("unknown"),
241 candidate_type.unwrap_or("unknown")
242 ),
243 });
244 }
245
246 let baseline_required = baseline_required.contains(param_name);
248 let candidate_required = candidate_required.contains(param_name);
249
250 if baseline_required && !candidate_required {
251 changes.push(ToolChange {
252 tool_name: tool_name.to_string(),
253 change_type: "Required ā Optional".to_string(),
254 class: ChangeClass::NonBreaking,
255 details: format!("Parameter '{}' is now optional", param_name),
256 });
257 } else if !baseline_required && candidate_required {
258 changes.push(ToolChange {
259 tool_name: tool_name.to_string(),
260 change_type: "Optional ā Required".to_string(),
261 class: ChangeClass::Breaking,
262 details: format!("Parameter '{}' is now required", param_name),
263 });
264 }
265
266 let baseline_enum = baseline_param.get("enum").and_then(|e| e.as_array());
268 let candidate_enum = candidate_param.get("enum").and_then(|e| e.as_array());
269
270 if let (Some(baseline_vals), Some(candidate_vals)) = (baseline_enum, candidate_enum) {
271 for val in baseline_vals {
273 if !candidate_vals.contains(val) {
274 changes.push(ToolChange {
275 tool_name: tool_name.to_string(),
276 change_type: "Enum value removed".to_string(),
277 class: ChangeClass::Breaking,
278 details: format!(
279 "Parameter '{}' enum value '{}' was removed",
280 param_name, val
281 ),
282 });
283 }
284 }
285
286 for val in candidate_vals {
288 if !baseline_vals.contains(val) {
289 changes.push(ToolChange {
290 tool_name: tool_name.to_string(),
291 change_type: "Enum value added".to_string(),
292 class: ChangeClass::NonBreaking,
293 details: format!(
294 "Parameter '{}' enum value '{}' was added",
295 param_name, val
296 ),
297 });
298 }
299 }
300 }
301 }
302 }
303
304 changes
305}
306
307pub fn print_report(report: &DiffReport) {
308 println!("\n============================================");
309 println!("DIFF REPORT");
310 println!("============================================");
311
312 if !report.breaking_changes.is_empty() {
313 println!("\nšØ BREAKING CHANGES:");
314 for change in &report.breaking_changes {
315 println!(
316 " ⢠{}: {}",
317 change.tool_name, change.details
318 );
319 }
320 }
321
322 if !report.non_breaking_changes.is_empty() {
323 println!("\nā ļø NON-BREAKING CHANGES:");
324 for change in &report.non_breaking_changes {
325 println!(
326 " ⢠{}: {}",
327 change.tool_name, change.details
328 );
329 }
330 }
331
332 if !report.additive_changes.is_empty() {
333 println!("\nā
ADDITIVE CHANGES:");
334 for change in &report.additive_changes {
335 println!(
336 " ⢠{}: {}",
337 change.tool_name, change.details
338 );
339 }
340 }
341
342 println!("\n============================================");
343 println!("Overall Verdict: {}", report.overall_verdict);
344 println!("============================================");
345}
346
347pub fn print_report_json(report: &DiffReport) {
348 let json = json!({
349 "overall_verdict": report.overall_verdict,
350 "breaking_changes": report.breaking_changes,
351 "non_breaking_changes": report.non_breaking_changes,
352 "additive_changes": report.additive_changes,
353 });
354 println!("{}", serde_json::to_string_pretty(&json).unwrap());
355}