Skip to main content

mockforge_intelligence/ai_contract_diff/
diff_analyzer.rs

1//! Core diff analysis engine for contract comparison
2//!
3//! This module performs structural comparison between captured requests and contract
4//! specifications, detecting mismatches and preparing data for AI-powered recommendations.
5
6use super::types::{
7    CapturedRequest, ContractDiffResult, DiffMetadata, Mismatch, MismatchSeverity, MismatchType,
8};
9use mockforge_foundation::schema_diff::validation_diff;
10use mockforge_foundation::Result;
11use mockforge_openapi::OpenApiSpec;
12use serde_json::Value;
13use std::collections::HashMap;
14
15/// Check if a path matches a pattern with path parameters
16///
17/// Examples:
18/// - `/users/{id}` matches `/users/123`
19/// - `/users/{userId}/posts/{postId}` matches `/users/123/posts/456`
20fn path_matches_with_params(pattern: &str, path: &str) -> bool {
21    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
22    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
23
24    if pattern_parts.len() != path_parts.len() {
25        return false;
26    }
27
28    for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
29        // Check for path parameters {param} or {param:type}
30        if pattern_part.starts_with('{') && pattern_part.ends_with('}') {
31            // Matches any value (could add type validation here)
32            continue;
33        }
34
35        if pattern_part != path_part {
36            return false;
37        }
38    }
39
40    true
41}
42
43/// Contract diff analyzer
44pub struct DiffAnalyzer {
45    /// Configuration for analysis
46    config: super::types::ContractDiffConfig,
47}
48
49impl DiffAnalyzer {
50    /// Create a new diff analyzer
51    pub fn new(config: super::types::ContractDiffConfig) -> Self {
52        Self { config }
53    }
54
55    /// Analyze a captured request against an OpenAPI specification
56    pub async fn analyze_request(
57        &self,
58        request: &CapturedRequest,
59        spec: &OpenApiSpec,
60    ) -> Result<ContractDiffResult> {
61        let mut mismatches = Vec::new();
62
63        // Find matching endpoint in spec
64        let endpoint_match = self.find_endpoint_in_spec(&request.path, &request.method, spec);
65
66        // Analyze endpoint existence
67        if endpoint_match.is_none() {
68            mismatches.push(Mismatch {
69                mismatch_type: MismatchType::EndpointNotFound,
70                path: request.path.clone(),
71                method: Some(request.method.clone()),
72                expected: Some("Endpoint defined in OpenAPI spec".to_string()),
73                actual: Some("Endpoint not found in spec".to_string()),
74                description: format!(
75                    "Endpoint {} {} not found in contract specification",
76                    request.method, request.path
77                ),
78                severity: MismatchSeverity::Critical,
79                confidence: 1.0, // Structural mismatch is always certain
80                context: HashMap::new(),
81            });
82        }
83
84        // Analyze request body against schema
85        if let Some(body) = &request.body {
86            if let Some(endpoint) = &endpoint_match {
87                let body_mismatches =
88                    self.analyze_request_body(body, endpoint, &request.path, spec)?;
89                mismatches.extend(body_mismatches);
90            }
91        }
92
93        // Analyze headers
94        let header_mismatches = self.analyze_headers(&request.headers, endpoint_match.as_ref());
95        mismatches.extend(header_mismatches);
96
97        // Analyze query parameters
98        let query_mismatches = self.analyze_query_params(
99            &request.query_params,
100            endpoint_match.as_ref(),
101            &request.path,
102        );
103        mismatches.extend(query_mismatches);
104
105        // Calculate overall confidence
106        let overall_confidence =
107            super::confidence_scorer::ConfidenceScorer::calculate_overall_confidence(&mismatches);
108
109        // Create metadata
110        let metadata = DiffMetadata {
111            analyzed_at: chrono::Utc::now(),
112            request_source: request.source.clone(),
113            contract_version: spec.spec.info.version.clone().into(),
114            contract_format: "openapi-3.0".to_string(), // Could detect version
115            endpoint_path: request.path.clone(),
116            http_method: request.method.clone(),
117            request_count: 1,
118            llm_provider: Some(self.config.llm_provider.clone()),
119            llm_model: Some(self.config.llm_model.clone()),
120        };
121
122        Ok(ContractDiffResult {
123            matches: mismatches.is_empty(),
124            confidence: overall_confidence,
125            mismatches,
126            recommendations: Vec::new(), // Will be populated by recommendation engine
127            corrections: Vec::new(),     // Will be populated by correction proposer
128            metadata,
129        })
130    }
131
132    /// Find matching endpoint in OpenAPI spec
133    fn find_endpoint_in_spec(
134        &self,
135        path: &str,
136        method: &str,
137        spec: &OpenApiSpec,
138    ) -> Option<openapiv3::Operation> {
139        // Normalize path (remove query params, trailing slashes)
140        let normalized_path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
141
142        // Try exact match first
143        for (spec_path, path_item_ref) in &spec.spec.paths.paths {
144            let spec_path_normalized = spec_path.trim_end_matches('/');
145
146            if spec_path_normalized == normalized_path {
147                if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
148                    return match method.to_uppercase().as_str() {
149                        "GET" => path_item.get.clone(),
150                        "POST" => path_item.post.clone(),
151                        "PUT" => path_item.put.clone(),
152                        "DELETE" => path_item.delete.clone(),
153                        "PATCH" => path_item.patch.clone(),
154                        _ => None,
155                    };
156                }
157            }
158        }
159
160        // Path parameter matching (e.g., /users/{id} matches /users/123)
161        for (spec_path, path_item_ref) in &spec.spec.paths.paths {
162            let spec_path_normalized = spec_path.trim_end_matches('/');
163
164            if path_matches_with_params(spec_path_normalized, normalized_path) {
165                if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
166                    return match method.to_uppercase().as_str() {
167                        "GET" => path_item.get.clone(),
168                        "POST" => path_item.post.clone(),
169                        "PUT" => path_item.put.clone(),
170                        "DELETE" => path_item.delete.clone(),
171                        "PATCH" => path_item.patch.clone(),
172                        _ => None,
173                    };
174                }
175            }
176        }
177
178        None
179    }
180
181    /// Analyze request body against schema
182    fn analyze_request_body(
183        &self,
184        body: &Value,
185        operation: &openapiv3::Operation,
186        path: &str,
187        spec: &OpenApiSpec,
188    ) -> Result<Vec<Mismatch>> {
189        let mut mismatches = Vec::new();
190
191        // Get request body schema
192        if let Some(openapiv3::ReferenceOr::Item(request_body)) = &operation.request_body {
193            // Get JSON schema from content
194            if let Some(content) = request_body.content.get("application/json") {
195                if let Some(schema_ref) = &content.schema {
196                    // Convert OpenAPI schema to JSON Schema for validation
197                    let schema_value = self.openapi_schema_to_json(schema_ref, spec)?;
198
199                    // Use existing validation_diff function
200                    let validation_errors = validation_diff(&schema_value, body);
201
202                    // Convert validation errors to mismatches
203                    for error in &validation_errors {
204                        let mismatch_type = match error.error_type.as_str() {
205                            "missing_required" => MismatchType::MissingRequiredField,
206                            "type_mismatch" => MismatchType::TypeMismatch,
207                            "additional_property" => MismatchType::UnexpectedField,
208                            "length_mismatch" => MismatchType::ConstraintViolation,
209                            _ => MismatchType::SchemaMismatch,
210                        };
211
212                        let severity = match mismatch_type {
213                            MismatchType::MissingRequiredField => MismatchSeverity::Critical,
214                            MismatchType::TypeMismatch => MismatchSeverity::High,
215                            MismatchType::UnexpectedField => MismatchSeverity::Low,
216                            _ => MismatchSeverity::Medium,
217                        };
218
219                        mismatches.push(Mismatch {
220                            mismatch_type,
221                            path: format!("{}{}", path, error.path),
222                            method: None,
223                            expected: Some(error.expected.clone()),
224                            actual: Some(error.found.clone()),
225                            description: error.message.clone().unwrap_or_else(|| {
226                                format!("Validation error: {}", error.error_type)
227                            }),
228                            severity,
229                            confidence: 0.9, // Structural validation is high confidence
230                            context: error
231                                .schema_info
232                                .as_ref()
233                                .map(|info| {
234                                    let mut ctx = HashMap::new();
235                                    ctx.insert(
236                                        "data_type".to_string(),
237                                        Value::String(info.data_type.clone()),
238                                    );
239                                    if let Some(required) = info.required {
240                                        ctx.insert("required".to_string(), Value::Bool(required));
241                                    }
242                                    if let Some(format) = &info.format {
243                                        ctx.insert(
244                                            "format".to_string(),
245                                            Value::String(format.clone()),
246                                        );
247                                    }
248                                    ctx
249                                })
250                                .unwrap_or_default(),
251                        });
252                    }
253                }
254            }
255        }
256
257        Ok(mismatches)
258    }
259
260    /// Analyze headers against spec requirements
261    fn analyze_headers(
262        &self,
263        headers: &HashMap<String, String>,
264        operation: Option<&openapiv3::Operation>,
265    ) -> Vec<Mismatch> {
266        let mut mismatches = Vec::new();
267
268        if let Some(op) = operation {
269            // Check security requirements
270            if let Some(security) = &op.security {
271                for sec_req in security {
272                    // Check if required headers are present
273                    // This is simplified - real implementation would check OAuth, API keys, etc.
274                    for (name, _) in sec_req {
275                        let header_name_lower = name.to_lowercase();
276                        let found =
277                            headers.iter().any(|(k, _)| k.to_lowercase() == header_name_lower);
278
279                        if !found {
280                            mismatches.push(Mismatch {
281                                mismatch_type: MismatchType::HeaderMismatch,
282                                path: "headers".to_string(),
283                                method: None,
284                                expected: Some(format!("Header: {}", name)),
285                                actual: Some("Header missing".to_string()),
286                                description: format!(
287                                    "Required security header '{}' is missing",
288                                    name
289                                ),
290                                severity: MismatchSeverity::High,
291                                confidence: 1.0,
292                                context: HashMap::new(),
293                            });
294                        }
295                    }
296                }
297            }
298        }
299
300        mismatches
301    }
302
303    /// Analyze query parameters against spec
304    fn analyze_query_params(
305        &self,
306        query_params: &HashMap<String, String>,
307        operation: Option<&openapiv3::Operation>,
308        path: &str,
309    ) -> Vec<Mismatch> {
310        let mut mismatches = Vec::new();
311
312        if let Some(op) = operation {
313            // Check parameters
314            for param in &op.parameters {
315                if let openapiv3::ReferenceOr::Item(openapiv3::Parameter::Query {
316                    parameter_data,
317                    ..
318                }) = param
319                {
320                    let param_name = &parameter_data.name;
321                    let required = parameter_data.required;
322
323                    let found = query_params.contains_key(param_name);
324
325                    if required && !found {
326                        mismatches.push(Mismatch {
327                            mismatch_type: MismatchType::QueryParamMismatch,
328                            path: format!("{}?{}", path, param_name),
329                            method: None,
330                            expected: Some(format!("Required query parameter: {}", param_name)),
331                            actual: Some("Parameter missing".to_string()),
332                            description: format!(
333                                "Required query parameter '{}' is missing",
334                                param_name
335                            ),
336                            severity: MismatchSeverity::High,
337                            confidence: 1.0,
338                            context: HashMap::new(),
339                        });
340                    }
341                }
342            }
343        }
344
345        mismatches
346    }
347
348    /// Convert OpenAPI schema to JSON Schema value for validation
349    ///
350    /// This method resolves `$ref` references using the provided OpenAPI spec.
351    fn openapi_schema_to_json(
352        &self,
353        schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
354        spec: &OpenApiSpec,
355    ) -> Result<Value> {
356        match schema {
357            openapiv3::ReferenceOr::Item(schema) => {
358                self.openapi_schema_to_json_from_schema(schema, spec)
359            }
360            openapiv3::ReferenceOr::Reference { reference } => {
361                // Resolve the reference using the spec
362                if let Some(resolved_schema) = spec.resolve_schema_ref(reference) {
363                    self.openapi_schema_to_json_from_schema(&resolved_schema, spec)
364                } else {
365                    // Reference couldn't be resolved, return empty schema with warning
366                    tracing::warn!("Could not resolve schema reference: {}", reference);
367                    Ok(Value::Object(serde_json::Map::new()))
368                }
369            }
370        }
371    }
372
373    /// Convert a Schema directly (helper for Box<Schema> case)
374    ///
375    /// This method resolves `$ref` references for nested properties using the spec.
376    #[allow(clippy::only_used_in_recursion)]
377    fn openapi_schema_to_json_from_schema(
378        &self,
379        schema: &openapiv3::Schema,
380        spec: &OpenApiSpec,
381    ) -> Result<Value> {
382        let mut json_schema = serde_json::Map::new();
383
384        // Add type
385        match &schema.schema_kind {
386            openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
387                json_schema.insert("type".to_string(), Value::String("string".to_string()));
388            }
389            openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
390                json_schema.insert("type".to_string(), Value::String("number".to_string()));
391            }
392            openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
393                json_schema.insert("type".to_string(), Value::String("integer".to_string()));
394            }
395            openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
396                json_schema.insert("type".to_string(), Value::String("boolean".to_string()));
397            }
398            openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) => {
399                json_schema.insert("type".to_string(), Value::String("array".to_string()));
400                // Handle array items
401                if let Some(items) = &array_type.items {
402                    let items_json = match items {
403                        openapiv3::ReferenceOr::Item(item_schema) => {
404                            self.openapi_schema_to_json_from_schema(item_schema, spec)?
405                        }
406                        openapiv3::ReferenceOr::Reference { reference } => {
407                            // Resolve array item reference
408                            if let Some(resolved) = spec.resolve_schema_ref(reference.as_str()) {
409                                self.openapi_schema_to_json_from_schema(&resolved, spec)?
410                            } else {
411                                tracing::warn!(
412                                    "Could not resolve array item reference: {}",
413                                    reference
414                                );
415                                Value::Object(serde_json::Map::new())
416                            }
417                        }
418                    };
419                    json_schema.insert("items".to_string(), items_json);
420                }
421            }
422            openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => {
423                json_schema.insert("type".to_string(), Value::String("object".to_string()));
424            }
425            _ => {}
426        }
427
428        // Add properties if object
429        if let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj_type)) = &schema.schema_kind
430        {
431            let mut props = serde_json::Map::new();
432            for (name, prop_schema_ref) in &obj_type.properties {
433                // Handle ReferenceOr<Box<Schema>> with proper reference resolution
434                let prop_json = match prop_schema_ref {
435                    openapiv3::ReferenceOr::Item(boxed_schema) => {
436                        // Process the boxed schema directly
437                        self.openapi_schema_to_json_from_schema(boxed_schema.as_ref(), spec)
438                    }
439                    openapiv3::ReferenceOr::Reference { reference } => {
440                        // Resolve the property reference using the spec
441                        if let Some(resolved_schema) = spec.resolve_schema_ref(reference) {
442                            self.openapi_schema_to_json_from_schema(&resolved_schema, spec)
443                        } else {
444                            tracing::debug!(
445                                "Could not resolve property reference for '{}': {}",
446                                name,
447                                reference
448                            );
449                            Ok(Value::Object(serde_json::Map::new()))
450                        }
451                    }
452                };
453                if let Ok(prop_json) = prop_json {
454                    props.insert(name.clone(), prop_json);
455                }
456            }
457            if !props.is_empty() {
458                json_schema.insert("properties".to_string(), Value::Object(props));
459            }
460
461            // Add required fields
462            if !obj_type.required.is_empty() {
463                let required_array: Vec<Value> =
464                    obj_type.required.iter().map(|s| Value::String(s.clone())).collect();
465                json_schema.insert("required".to_string(), Value::Array(required_array));
466            }
467        }
468
469        Ok(Value::Object(json_schema))
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_diff_analyzer_creation() {
479        let config = crate::ai_contract_diff::ContractDiffConfig::default();
480        let _analyzer = DiffAnalyzer::new(config);
481        // DiffAnalyzer was successfully created with default config
482    }
483}