Skip to main content

trip_test/
diff.rs

1//! Contract diffing and change classification.
2//!
3//! Compares two MCP server contracts (baseline vs candidate) and classifies
4//! each change as breaking, non-breaking, or additive per the rules in section 12 of the PRD.
5
6use anyhow::Result;
7use serde::Serialize;
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use crate::mcp::Tool;
11use crate::snapshot::ToolInfo;
12
13/// Classification of a contract change.
14#[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    // Build maps for easy lookup
59    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    // Check for removed tools
70    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    // Check for added tools
82    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    // Check for schema changes in existing tools
94    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    // Determine overall verdict
108    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    // Get input schemas
128    let baseline_schema = baseline.input_schema.as_ref();
129    let candidate_schema = candidate.input_schema.as_ref();
130
131    // Compare descriptions
132    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    // Compare input schemas
145    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    // Check for removed parameters
189    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    // Check for added parameters
205    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    // Check for type changes
227    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            // Check for required → optional or optional → required
247            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            // Check enum changes
267            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                // Check for removed enum values
272                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                // Check for added enum values
287                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}