use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::mcp::Tool;
use crate::snapshot::ToolInfo;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum ChangeClass {
#[serde(rename = "BREAKING")]
Breaking,
#[serde(rename = "NON-BREAKING")]
NonBreaking,
#[serde(rename = "ADDITIVE")]
Additive,
}
impl std::fmt::Display for ChangeClass {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ChangeClass::Breaking => write!(f, "BREAKING"),
ChangeClass::NonBreaking => write!(f, "NON-BREAKING"),
ChangeClass::Additive => write!(f, "ADDITIVE"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolChange {
pub tool_name: String,
pub change_type: String,
pub class: ChangeClass,
pub details: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffReport {
pub breaking_changes: Vec<ToolChange>,
pub non_breaking_changes: Vec<ToolChange>,
pub additive_changes: Vec<ToolChange>,
pub overall_verdict: ChangeClass,
}
pub fn diff_tools(
baseline_tools: Vec<ToolInfo>,
candidate_tools: Vec<Tool>,
) -> Result<DiffReport> {
let mut breaking = Vec::new();
let mut non_breaking = Vec::new();
let mut additive = Vec::new();
let baseline_map: HashMap<String, ToolInfo> = baseline_tools
.into_iter()
.map(|t| (t.name.clone(), t))
.collect();
let candidate_map: HashMap<String, Tool> = candidate_tools
.into_iter()
.map(|t| (t.name.clone(), t))
.collect();
for (name, _) in &baseline_map {
if !candidate_map.contains_key(name) {
breaking.push(ToolChange {
tool_name: name.clone(),
change_type: "Tool removed".to_string(),
class: ChangeClass::Breaking,
details: format!("Tool '{}' was removed", name),
});
}
}
for (name, _) in &candidate_map {
if !baseline_map.contains_key(name) {
additive.push(ToolChange {
tool_name: name.clone(),
change_type: "Tool added".to_string(),
class: ChangeClass::Additive,
details: format!("Tool '{}' was added", name),
});
}
}
for (name, baseline_tool) in &baseline_map {
if let Some(candidate_tool) = candidate_map.get(name) {
let changes = diff_schemas(name, baseline_tool, candidate_tool);
for change in changes {
match change.class {
ChangeClass::Breaking => breaking.push(change),
ChangeClass::NonBreaking => non_breaking.push(change),
ChangeClass::Additive => additive.push(change),
}
}
}
}
let overall_verdict = if !breaking.is_empty() {
ChangeClass::Breaking
} else if !non_breaking.is_empty() || !additive.is_empty() {
ChangeClass::NonBreaking
} else {
ChangeClass::Additive
};
Ok(DiffReport {
breaking_changes: breaking,
non_breaking_changes: non_breaking,
additive_changes: additive,
overall_verdict,
})
}
fn diff_schemas(tool_name: &str, baseline: &ToolInfo, candidate: &Tool) -> Vec<ToolChange> {
let mut changes = Vec::new();
let baseline_schema = baseline.input_schema.as_ref();
let candidate_schema = candidate.input_schema.as_ref();
if baseline.description != candidate.description {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Description changed".to_string(),
class: ChangeClass::NonBreaking,
details: format!(
"Description changed from '{}' to '{}'",
baseline.description, candidate.description
),
});
}
if let (Some(baseline_sch), Some(candidate_sch)) = (baseline_schema, candidate_schema) {
let param_changes = diff_input_params(tool_name, baseline_sch, candidate_sch);
changes.extend(param_changes);
}
changes
}
fn diff_input_params(tool_name: &str, baseline: &Value, candidate: &Value) -> Vec<ToolChange> {
let mut changes = Vec::new();
let baseline_props = baseline
.get("properties")
.and_then(|p| p.as_object())
.map(|p| p.clone())
.unwrap_or_default();
let candidate_props = candidate
.get("properties")
.and_then(|p| p.as_object())
.map(|p| p.clone())
.unwrap_or_default();
let baseline_required = baseline
.get("required")
.and_then(|r| r.as_array())
.map(|r| {
r.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let candidate_required = candidate
.get("required")
.and_then(|r| r.as_array())
.map(|r| {
r.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
for (param_name, _baseline_param) in &baseline_props {
if !candidate_props.contains_key(param_name) {
let was_required = baseline_required.contains(param_name);
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Parameter removed".to_string(),
class: ChangeClass::Breaking,
details: format!(
"Parameter '{}' was removed (was required: {})",
param_name, was_required
),
});
}
}
for (param_name, _) in &candidate_props {
if !baseline_props.contains_key(param_name) {
let is_required = candidate_required.contains(param_name);
let class = if is_required {
ChangeClass::Breaking
} else {
ChangeClass::Additive
};
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Parameter added".to_string(),
class,
details: format!(
"Parameter '{}' was added (required: {})",
param_name, is_required
),
});
}
}
for (param_name, baseline_param) in &baseline_props {
if let Some(candidate_param) = candidate_props.get(param_name) {
let baseline_type = baseline_param.get("type").and_then(|t| t.as_str());
let candidate_type = candidate_param.get("type").and_then(|t| t.as_str());
if baseline_type != candidate_type {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Parameter type changed".to_string(),
class: ChangeClass::Breaking,
details: format!(
"Parameter '{}' type changed from '{}' to '{}'",
param_name,
baseline_type.unwrap_or("unknown"),
candidate_type.unwrap_or("unknown")
),
});
}
let baseline_required = baseline_required.contains(param_name);
let candidate_required = candidate_required.contains(param_name);
if baseline_required && !candidate_required {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Required → Optional".to_string(),
class: ChangeClass::NonBreaking,
details: format!("Parameter '{}' is now optional", param_name),
});
} else if !baseline_required && candidate_required {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Optional → Required".to_string(),
class: ChangeClass::Breaking,
details: format!("Parameter '{}' is now required", param_name),
});
}
let baseline_enum = baseline_param.get("enum").and_then(|e| e.as_array());
let candidate_enum = candidate_param.get("enum").and_then(|e| e.as_array());
if let (Some(baseline_vals), Some(candidate_vals)) = (baseline_enum, candidate_enum) {
for val in baseline_vals {
if !candidate_vals.contains(val) {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Enum value removed".to_string(),
class: ChangeClass::Breaking,
details: format!(
"Parameter '{}' enum value '{}' was removed",
param_name, val
),
});
}
}
for val in candidate_vals {
if !baseline_vals.contains(val) {
changes.push(ToolChange {
tool_name: tool_name.to_string(),
change_type: "Enum value added".to_string(),
class: ChangeClass::NonBreaking,
details: format!(
"Parameter '{}' enum value '{}' was added",
param_name, val
),
});
}
}
}
}
}
changes
}
pub fn print_report(report: &DiffReport) {
println!("\n============================================");
println!("DIFF REPORT");
println!("============================================");
if !report.breaking_changes.is_empty() {
println!("\n🚨 BREAKING CHANGES:");
for change in &report.breaking_changes {
println!(
" • {}: {}",
change.tool_name, change.details
);
}
}
if !report.non_breaking_changes.is_empty() {
println!("\n⚠️ NON-BREAKING CHANGES:");
for change in &report.non_breaking_changes {
println!(
" • {}: {}",
change.tool_name, change.details
);
}
}
if !report.additive_changes.is_empty() {
println!("\n✅ ADDITIVE CHANGES:");
for change in &report.additive_changes {
println!(
" • {}: {}",
change.tool_name, change.details
);
}
}
println!("\n============================================");
println!("Overall Verdict: {}", report.overall_verdict);
println!("============================================");
}
pub fn print_report_json(report: &DiffReport) {
let json = json!({
"overall_verdict": report.overall_verdict,
"breaking_changes": report.breaking_changes,
"non_breaking_changes": report.non_breaking_changes,
"additive_changes": report.additive_changes,
});
println!("{}", serde_json::to_string_pretty(&json).unwrap());
}