trip-test 0.1.1

Contract testing & regression safety for MCP servers
Documentation
//! Contract diffing and change classification.
//!
//! Compares two MCP server contracts (baseline vs candidate) and classifies
//! each change as breaking, non-breaking, or additive per the rules in section 12 of the PRD.

use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::mcp::Tool;
use crate::snapshot::ToolInfo;

/// Classification of a contract change.
#[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();

    // Build maps for easy lookup
    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();

    // Check for removed tools
    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),
            });
        }
    }

    // Check for added tools
    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),
            });
        }
    }

    // Check for schema changes in existing tools
    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),
                }
            }
        }
    }

    // Determine overall verdict
    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();

    // Get input schemas
    let baseline_schema = baseline.input_schema.as_ref();
    let candidate_schema = candidate.input_schema.as_ref();

    // Compare descriptions
    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
            ),
        });
    }

    // Compare input schemas
    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();

    // Check for removed parameters
    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
                ),
            });
        }
    }

    // Check for added parameters
    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
                ),
            });
        }
    }

    // Check for type changes
    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")
                    ),
                });
            }

            // Check for required → optional or optional → required
            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),
                });
            }

            // Check enum changes
            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) {
                // Check for removed enum values
                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
                            ),
                        });
                    }
                }

                // Check for added enum values
                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());
}