checkmate-cli 0.4.1

Checkmate - API Testing Framework CLI
//! JSON structural diff engine for API version comparison

use serde::Serialize;

/// A single structural change between two JSON values
#[derive(Debug, Clone, Serialize)]
pub struct DiffChange {
    pub change_type: String,
    pub path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<serde_json::Value>,
}

/// Result of comparing two endpoint responses
#[derive(Debug, Clone, Serialize)]
pub struct DiffResult {
    pub base_endpoint: String,
    pub target_endpoint: String,
    pub changes: Vec<DiffChange>,
    pub additions: usize,
    pub removals: usize,
    pub type_changes: usize,
    pub value_changes: usize,
    pub base_response: serde_json::Value,
    pub target_response: serde_json::Value,
}

/// Recursively compare two JSON values and collect structural differences
pub fn compare_json(
    base: &serde_json::Value,
    target: &serde_json::Value,
    path: &str,
) -> Vec<DiffChange> {
    let mut changes = Vec::new();

    match (base, target) {
        (serde_json::Value::Object(base_map), serde_json::Value::Object(target_map)) => {
            // Check for removed keys
            for key in base_map.keys() {
                if !target_map.contains_key(key) {
                    let field_path = if path.is_empty() {
                        key.clone()
                    } else {
                        format!("{}.{}", path, key)
                    };
                    changes.push(DiffChange {
                        change_type: "removed".to_string(),
                        path: field_path,
                        from: Some(base_map[key].clone()),
                        to: None,
                    });
                }
            }
            // Check for added keys and recurse into shared keys
            for key in target_map.keys() {
                let field_path = if path.is_empty() {
                    key.clone()
                } else {
                    format!("{}.{}", path, key)
                };
                if !base_map.contains_key(key) {
                    changes.push(DiffChange {
                        change_type: "added".to_string(),
                        path: field_path,
                        from: None,
                        to: Some(target_map[key].clone()),
                    });
                } else {
                    changes.extend(compare_json(&base_map[key], &target_map[key], &field_path));
                }
            }
        }
        (serde_json::Value::Array(base_arr), serde_json::Value::Array(target_arr)) => {
            let max_len = base_arr.len().max(target_arr.len());
            for i in 0..max_len {
                let idx_path = if path.is_empty() {
                    format!("[{}]", i)
                } else {
                    format!("{}[{}]", path, i)
                };
                match (base_arr.get(i), target_arr.get(i)) {
                    (Some(b), Some(t)) => {
                        changes.extend(compare_json(b, t, &idx_path));
                    }
                    (Some(b), None) => {
                        changes.push(DiffChange {
                            change_type: "removed".to_string(),
                            path: idx_path,
                            from: Some(b.clone()),
                            to: None,
                        });
                    }
                    (None, Some(t)) => {
                        changes.push(DiffChange {
                            change_type: "added".to_string(),
                            path: idx_path,
                            from: None,
                            to: Some(t.clone()),
                        });
                    }
                    (None, None) => unreachable!(),
                }
            }
        }
        _ => {
            // Different types
            if json_type_name(base) != json_type_name(target) {
                changes.push(DiffChange {
                    change_type: "type_changed".to_string(),
                    path: path.to_string(),
                    from: Some(serde_json::Value::String(json_type_name(base).to_string())),
                    to: Some(serde_json::Value::String(json_type_name(target).to_string())),
                });
            } else if base != target {
                changes.push(DiffChange {
                    change_type: "value_changed".to_string(),
                    path: path.to_string(),
                    from: Some(base.clone()),
                    to: Some(target.clone()),
                });
            }
        }
    }

    changes
}

fn json_type_name(value: &serde_json::Value) -> &'static str {
    match value {
        serde_json::Value::Null => "null",
        serde_json::Value::Bool(_) => "boolean",
        serde_json::Value::Number(_) => "number",
        serde_json::Value::String(_) => "string",
        serde_json::Value::Array(_) => "array",
        serde_json::Value::Object(_) => "object",
    }
}

/// Convert a DiffResult to a JSON value for assertion evaluation
pub fn diff_result_to_json(result: &DiffResult) -> serde_json::Value {
    serde_json::to_value(result).unwrap_or(serde_json::Value::Null)
}

/// Format diff for TTY output with ANSI colors
pub fn format_diff_tty(result: &DiffResult) -> String {
    let mut out = String::new();

    out.push_str(&format!(
        "\n\x1b[1m--- {}\n+++ {}\x1b[0m\n\n",
        result.base_endpoint, result.target_endpoint
    ));

    if result.changes.is_empty() {
        out.push_str("  \x1b[32mNo structural differences\x1b[0m\n");
        return out;
    }

    for change in &result.changes {
        match change.change_type.as_str() {
            "added" => {
                out.push_str(&format!(
                    "  \x1b[32m+ {}: {}\x1b[0m\n",
                    change.path,
                    change.to.as_ref().map_or("".to_string(), |v| format_value(v))
                ));
            }
            "removed" => {
                out.push_str(&format!(
                    "  \x1b[31m- {}: {}\x1b[0m\n",
                    change.path,
                    change.from.as_ref().map_or("".to_string(), |v| format_value(v))
                ));
            }
            "type_changed" => {
                out.push_str(&format!(
                    "  \x1b[33m~ {} : {} → {}\x1b[0m\n",
                    change.path,
                    change.from.as_ref().map_or("?".to_string(), |v| v.as_str().unwrap_or("?").to_string()),
                    change.to.as_ref().map_or("?".to_string(), |v| v.as_str().unwrap_or("?").to_string()),
                ));
            }
            "value_changed" => {
                out.push_str(&format!(
                    "  \x1b[33m~ {}: {} → {}\x1b[0m\n",
                    change.path,
                    change.from.as_ref().map_or("".to_string(), |v| format_value(v)),
                    change.to.as_ref().map_or("".to_string(), |v| format_value(v)),
                ));
            }
            _ => {}
        }
    }

    out.push_str(&format!(
        "\n  Summary: {} addition(s), {} removal(s), {} type change(s), {} value change(s)\n",
        result.additions, result.removals, result.type_changes, result.value_changes
    ));

    out
}

fn format_value(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(s) => format!("\"{}\"", s),
        other => other.to_string(),
    }
}

impl DiffResult {
    /// Build a DiffResult from two endpoint responses
    pub fn from_comparison(
        base_endpoint: String,
        target_endpoint: String,
        base_response: serde_json::Value,
        target_response: serde_json::Value,
    ) -> Self {
        let changes = compare_json(&base_response, &target_response, "");

        let additions = changes.iter().filter(|c| c.change_type == "added").count();
        let removals = changes.iter().filter(|c| c.change_type == "removed").count();
        let type_changes = changes.iter().filter(|c| c.change_type == "type_changed").count();
        let value_changes = changes.iter().filter(|c| c.change_type == "value_changed").count();

        Self {
            base_endpoint,
            target_endpoint,
            changes,
            additions,
            removals,
            type_changes,
            value_changes,
            base_response,
            target_response,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_compare_identical() {
        let a = serde_json::json!({"name": "Alice", "age": 30});
        let b = serde_json::json!({"name": "Alice", "age": 30});
        let changes = compare_json(&a, &b, "");
        assert!(changes.is_empty());
    }

    #[test]
    fn test_compare_added_field() {
        let base = serde_json::json!({"name": "Alice"});
        let target = serde_json::json!({"name": "Alice", "email": "alice@example.com"});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, "added");
        assert_eq!(changes[0].path, "email");
    }

    #[test]
    fn test_compare_removed_field() {
        let base = serde_json::json!({"name": "Alice", "legacy_id": 42});
        let target = serde_json::json!({"name": "Alice"});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, "removed");
        assert_eq!(changes[0].path, "legacy_id");
    }

    #[test]
    fn test_compare_type_changed() {
        let base = serde_json::json!({"count": "42"});
        let target = serde_json::json!({"count": 42});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, "type_changed");
        assert_eq!(changes[0].path, "count");
    }

    #[test]
    fn test_compare_value_changed() {
        let base = serde_json::json!({"version": "1.0"});
        let target = serde_json::json!({"version": "2.0"});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, "value_changed");
        assert_eq!(changes[0].path, "version");
    }

    #[test]
    fn test_compare_nested() {
        let base = serde_json::json!({"user": {"name": "Alice", "role": "user"}});
        let target = serde_json::json!({"user": {"name": "Alice", "role": "admin", "level": 5}});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 2);

        let value_change = changes.iter().find(|c| c.path == "user.role").unwrap();
        assert_eq!(value_change.change_type, "value_changed");

        let added = changes.iter().find(|c| c.path == "user.level").unwrap();
        assert_eq!(added.change_type, "added");
    }

    #[test]
    fn test_compare_array_length_diff() {
        let base = serde_json::json!({"items": [1, 2]});
        let target = serde_json::json!({"items": [1, 2, 3]});
        let changes = compare_json(&base, &target, "");
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, "added");
        assert_eq!(changes[0].path, "items[2]");
    }

    #[test]
    fn test_diff_result_from_comparison() {
        let base = serde_json::json!({"status": "ok", "legacy": true});
        let target = serde_json::json!({"status": "ok", "version": "2.0"});
        let result = DiffResult::from_comparison(
            "/v1/api".to_string(),
            "/v2/api".to_string(),
            base,
            target,
        );
        assert_eq!(result.additions, 1);
        assert_eq!(result.removals, 1);
        assert_eq!(result.type_changes, 0);
        assert_eq!(result.value_changes, 0);
    }

    #[test]
    fn test_format_tty_no_changes() {
        let result = DiffResult::from_comparison(
            "/v1".to_string(),
            "/v2".to_string(),
            serde_json::json!({"a": 1}),
            serde_json::json!({"a": 1}),
        );
        let output = format_diff_tty(&result);
        assert!(output.contains("No structural differences"));
    }
}