Skip to main content

mockforge_intelligence/ai_studio/
contract_diff_handler.rs

1//! Contract Diff Handler for processing natural language queries
2//!
3//! This module provides functionality to process natural language queries about
4//! contract diffs, enabling users to ask questions like "show me breaking changes"
5//! or "compare the last 3 versions" via the AI Studio chat interface.
6
7use crate::ai_contract_diff::{
8    CapturedRequest, ContractDiffAnalyzer, ContractDiffConfig, ContractDiffResult, DiffMetadata,
9    Mismatch, MismatchSeverity, MismatchType,
10};
11use crate::contract_validation::ContractValidator;
12use crate::intelligent_behavior::{config::IntelligentBehaviorConfig, llm_client::LlmClient};
13use chrono::Utc;
14use mockforge_foundation::Result;
15use mockforge_openapi::OpenApiSpec;
16use serde::{Deserialize, Serialize};
17
18/// Contract diff handler for NL queries
19pub struct ContractDiffHandler {
20    /// LLM client for parsing queries
21    #[allow(dead_code)]
22    llm_client: LlmClient,
23    /// Contract diff analyzer
24    analyzer: ContractDiffAnalyzer,
25    /// Configuration
26    #[allow(dead_code)]
27    config: IntelligentBehaviorConfig,
28}
29
30impl ContractDiffHandler {
31    /// Create a new contract diff handler
32    pub fn new() -> Result<Self> {
33        let config = IntelligentBehaviorConfig::default();
34        let llm_client = LlmClient::new(config.behavior_model.clone());
35        let diff_config = ContractDiffConfig {
36            enabled: true,
37            llm_provider: config.behavior_model.llm_provider.clone(),
38            llm_model: config.behavior_model.model.clone(),
39            confidence_threshold: 0.5,
40            ..Default::default()
41        };
42        let analyzer = ContractDiffAnalyzer::new(diff_config)?;
43
44        Ok(Self {
45            llm_client,
46            analyzer,
47            config,
48        })
49    }
50
51    /// Create with custom configuration
52    pub fn with_config(config: IntelligentBehaviorConfig) -> Result<Self> {
53        let llm_client = LlmClient::new(config.behavior_model.clone());
54        let diff_config = ContractDiffConfig {
55            enabled: true,
56            llm_provider: config.behavior_model.llm_provider.clone(),
57            llm_model: config.behavior_model.model.clone(),
58            confidence_threshold: 0.5,
59            ..Default::default()
60        };
61        let analyzer = ContractDiffAnalyzer::new(diff_config)?;
62
63        Ok(Self {
64            llm_client,
65            analyzer,
66            config,
67        })
68    }
69
70    /// Analyze a contract diff from a natural language query
71    ///
72    /// Parses the query to extract:
73    /// - Which spec/request to analyze
74    /// - What type of analysis to perform
75    /// - Any filters (breaking changes only, mobile endpoints, etc.)
76    pub async fn analyze_from_query(
77        &self,
78        query: &str,
79        spec: Option<&OpenApiSpec>,
80        captured_request: Option<CapturedRequest>,
81    ) -> Result<ContractDiffQueryResult> {
82        // Parse the query to understand intent
83        let intent = self.parse_query_intent(query).await?;
84
85        match intent {
86            ContractDiffIntent::AnalyzeRequest { request_id, filters } => {
87                // Analyze a specific captured request
88                if let Some(request) = captured_request {
89                    if let Some(spec) = spec {
90                        let result = self.analyzer.analyze(&request, spec).await?;
91                        let breaking_changes = self.extract_breaking_changes(&result);
92                        let summary = self.generate_summary(&result, &filters).await?;
93                        Ok(ContractDiffQueryResult {
94                            intent: ContractDiffIntent::AnalyzeRequest {
95                                request_id: None,
96                                filters: filters.clone(),
97                            },
98                            result: Some(result),
99                            summary,
100                            breaking_changes,
101                            link_to_viewer: Some(format!("/contract-diff?request_id={}", request_id.unwrap_or_default())),
102                        })
103                    } else {
104                        Err(mockforge_foundation::Error::internal("OpenAPI spec is required for analysis"))
105                    }
106                } else {
107                    Err(mockforge_foundation::Error::internal("Captured request is required for analysis"))
108                }
109            }
110            ContractDiffIntent::CompareVersions { spec1_path, spec2_path, filters } => {
111                // Compare two contract versions
112                // This would require loading both specs
113                Ok(ContractDiffQueryResult {
114                    intent: ContractDiffIntent::CompareVersions {
115                        spec1_path: spec1_path.clone(),
116                        spec2_path: spec2_path.clone(),
117                        filters: filters.clone(),
118                    },
119                    result: None,
120                    summary: format!(
121                        "To compare versions, please provide both OpenAPI specifications. Spec 1: {}, Spec 2: {}",
122                        spec1_path.unwrap_or_else(|| "not specified".to_string()),
123                        spec2_path.unwrap_or_else(|| "not specified".to_string())
124                    ),
125                    breaking_changes: Vec::new(),
126                    link_to_viewer: Some("/contract-diff/compare".to_string()),
127                })
128            }
129            ContractDiffIntent::SummarizeDrift { filters } => {
130                // Summarize contract drift
131                Ok(ContractDiffQueryResult {
132                    intent: ContractDiffIntent::SummarizeDrift { filters: filters.clone() },
133                    result: None,
134                    summary: "Drift summary would be generated from recent contract diff analyses. Use the Contract Diff page to view detailed drift history.".to_string(),
135                    breaking_changes: Vec::new(),
136                    link_to_viewer: Some("/contract-diff".to_string()),
137                })
138            }
139            ContractDiffIntent::FindBreakingChanges { filters } => {
140                // Find breaking changes
141                if let Some(_spec) = spec {
142                    // This is a simplified version - in practice, you'd compare against a previous version
143                    Ok(ContractDiffQueryResult {
144                        intent: ContractDiffIntent::FindBreakingChanges { filters: filters.clone() },
145                        result: None,
146                        summary: "Breaking changes analysis requires comparing against a previous contract version. Use the Contract Diff page to compare versions.".to_string(),
147                        breaking_changes: Vec::new(),
148                        link_to_viewer: Some("/contract-diff".to_string()),
149                    })
150                } else {
151                    Err(mockforge_foundation::Error::internal("OpenAPI spec is required for breaking changes analysis"))
152                }
153            }
154            ContractDiffIntent::Unknown => {
155                Ok(ContractDiffQueryResult {
156                    intent: ContractDiffIntent::Unknown,
157                    result: None,
158                    summary: "I can help with contract diff analysis! Try asking:\n- \"Analyze the last captured request\"\n- \"Show me breaking changes\"\n- \"Compare contract versions\"\n- \"Summarize drift for mobile endpoints\"".to_string(),
159                    breaking_changes: Vec::new(),
160                    link_to_viewer: None,
161                })
162            }
163        }
164    }
165
166    /// Compare two contract versions using ContractValidator
167    pub async fn compare_versions(
168        &self,
169        spec1: &OpenApiSpec,
170        spec2: &OpenApiSpec,
171    ) -> Result<ContractDiffResult> {
172        let validator = ContractValidator::new();
173        let validation_result = validator.compare_specs(spec1, spec2);
174
175        // Convert ValidationResult into ContractDiffResult
176        let mismatches: Vec<Mismatch> = validation_result
177            .errors
178            .iter()
179            .map(|err| Mismatch {
180                mismatch_type: if err.is_breaking_change {
181                    MismatchType::SchemaMismatch
182                } else {
183                    MismatchType::ConstraintViolation
184                },
185                path: err.path.clone(),
186                method: None,
187                expected: err.expected.clone(),
188                actual: err.actual.clone(),
189                description: err.message.clone(),
190                severity: if err.is_breaking_change {
191                    MismatchSeverity::Critical
192                } else {
193                    MismatchSeverity::High
194                },
195                confidence: 1.0,
196                context: std::collections::HashMap::new(),
197            })
198            .chain(validation_result.breaking_changes.iter().map(|bc| Mismatch {
199                mismatch_type: MismatchType::SchemaMismatch,
200                path: bc.path.clone(),
201                method: None,
202                expected: None,
203                actual: None,
204                description: bc.description.clone(),
205                severity: MismatchSeverity::Critical,
206                confidence: 1.0,
207                context: std::collections::HashMap::new(),
208            }))
209            .collect();
210
211        Ok(ContractDiffResult {
212            matches: validation_result.passed,
213            confidence: 1.0,
214            mismatches,
215            recommendations: Vec::new(),
216            corrections: Vec::new(),
217            metadata: DiffMetadata {
218                analyzed_at: Utc::now(),
219                request_source: "version_comparison".to_string(),
220                contract_version: None,
221                contract_format: "openapi-3.0".to_string(),
222                endpoint_path: "/".to_string(),
223                http_method: "ALL".to_string(),
224                request_count: 0,
225                llm_provider: None,
226                llm_model: None,
227            },
228        })
229    }
230
231    /// Summarize contract drift
232    ///
233    /// Generates a human-readable summary of contract drift based on recent analyses.
234    pub async fn summarize_drift(
235        &self,
236        results: &[ContractDiffResult],
237        filters: &ContractDiffFilters,
238    ) -> Result<String> {
239        if results.is_empty() {
240            return Ok("No contract drift detected in recent analyses.".to_string());
241        }
242
243        let total_mismatches: usize = results.iter().map(|r| r.mismatches.len()).sum();
244        let breaking_count = results
245            .iter()
246            .flat_map(|r| &r.mismatches)
247            .filter(|m| m.severity == MismatchSeverity::Critical)
248            .count();
249
250        let mut summary = format!(
251            "Contract drift summary:\n- Total analyses: {}\n- Total mismatches: {}\n- Breaking changes: {}",
252            results.len(),
253            total_mismatches,
254            breaking_count
255        );
256
257        // Apply filters
258        if let Some(ref endpoint_filter) = filters.endpoint_filter {
259            summary.push_str(&format!("\n- Filtered by endpoint: {}", endpoint_filter));
260        }
261
262        if filters.breaking_only {
263            summary.push_str("\n- Showing breaking changes only");
264        }
265
266        Ok(summary)
267    }
268
269    /// Find breaking changes in contract diff results
270    pub fn find_breaking_changes(&self, result: &ContractDiffResult) -> Vec<BreakingChange> {
271        result
272            .mismatches
273            .iter()
274            .filter(|m| m.severity == MismatchSeverity::Critical)
275            .map(|m| BreakingChange {
276                path: m.path.clone(),
277                method: m.method.clone(),
278                description: m.description.clone(),
279                impact: "High - This change will break existing clients".to_string(),
280            })
281            .collect()
282    }
283
284    /// Extract breaking changes from result
285    fn extract_breaking_changes(&self, result: &ContractDiffResult) -> Vec<BreakingChange> {
286        self.find_breaking_changes(result)
287    }
288
289    /// Generate a summary from contract diff result
290    async fn generate_summary(
291        &self,
292        result: &ContractDiffResult,
293        filters: &ContractDiffFilters,
294    ) -> Result<String> {
295        if result.matches {
296            return Ok("Contract validation passed - no mismatches detected.".to_string());
297        }
298
299        let mut summary =
300            format!("Found {} mismatch(es) between request and contract.", result.mismatches.len());
301
302        if filters.breaking_only {
303            let breaking = result
304                .mismatches
305                .iter()
306                .filter(|m| m.severity == MismatchSeverity::Critical)
307                .count();
308            summary = format!("Found {} breaking change(s).", breaking);
309        }
310
311        if !result.recommendations.is_empty() {
312            summary.push_str(&format!(
313                "\n\n{} AI-powered recommendation(s) available.",
314                result.recommendations.len()
315            ));
316        }
317
318        if !result.corrections.is_empty() {
319            summary.push_str(&format!(
320                "\n\n{} correction proposal(s) available.",
321                result.corrections.len()
322            ));
323        }
324
325        Ok(summary)
326    }
327
328    /// Parse query intent from natural language
329    async fn parse_query_intent(&self, query: &str) -> Result<ContractDiffIntent> {
330        let query_lower = query.to_lowercase();
331
332        // Simple keyword-based intent detection (can be enhanced with LLM)
333        if query_lower.contains("analyze") || query_lower.contains("check") {
334            // Extract request ID if mentioned
335            let request_id = self.extract_request_id(query);
336            let filters = self.extract_filters(query);
337            return Ok(ContractDiffIntent::AnalyzeRequest {
338                request_id,
339                filters,
340            });
341        }
342
343        if query_lower.contains("compare") || query_lower.contains("diff") {
344            let (spec1, spec2) = self.extract_spec_paths(query);
345            let filters = self.extract_filters(query);
346            return Ok(ContractDiffIntent::CompareVersions {
347                spec1_path: spec1,
348                spec2_path: spec2,
349                filters,
350            });
351        }
352
353        if query_lower.contains("summarize")
354            || query_lower.contains("summary")
355            || query_lower.contains("drift")
356        {
357            let filters = self.extract_filters(query);
358            return Ok(ContractDiffIntent::SummarizeDrift { filters });
359        }
360
361        if query_lower.contains("breaking") || query_lower.contains("breaking change") {
362            let filters = self.extract_filters(query);
363            return Ok(ContractDiffIntent::FindBreakingChanges { filters });
364        }
365
366        Ok(ContractDiffIntent::Unknown)
367    }
368
369    /// Extract request ID from query (simple pattern matching)
370    fn extract_request_id(&self, query: &str) -> Option<String> {
371        // Look for patterns like "request id: abc123" or "request abc123"
372        for word in query.split_whitespace() {
373            if word.len() > 10 {
374                // Likely a UUID or request ID
375                return Some(word.to_string());
376            }
377        }
378        None
379    }
380
381    /// Extract spec paths from query
382    fn extract_spec_paths(&self, query: &str) -> (Option<String>, Option<String>) {
383        // Simple extraction - look for file paths or URLs
384        let words: Vec<&str> = query.split_whitespace().collect();
385        let mut paths = Vec::new();
386
387        for word in words {
388            if word.ends_with(".yaml")
389                || word.ends_with(".yml")
390                || word.ends_with(".json")
391                || word.starts_with("http")
392            {
393                paths.push(word.to_string());
394            }
395        }
396
397        match paths.len() {
398            0 => (None, None),
399            1 => (Some(paths[0].clone()), None),
400            _ => (Some(paths[0].clone()), Some(paths[1].clone())),
401        }
402    }
403
404    /// Extract filters from query
405    fn extract_filters(&self, query: &str) -> ContractDiffFilters {
406        let query_lower = query.to_lowercase();
407        ContractDiffFilters {
408            breaking_only: query_lower.contains("breaking")
409                || query_lower.contains("breaking change"),
410            endpoint_filter: if query_lower.contains("mobile") {
411                Some("mobile".to_string())
412            } else if query_lower.contains("api") {
413                Some("api".to_string())
414            } else {
415                None
416            },
417        }
418    }
419}
420
421impl Default for ContractDiffHandler {
422    fn default() -> Self {
423        Self::new().expect("Failed to create ContractDiffHandler")
424    }
425}
426
427/// Intent detected from natural language query
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub enum ContractDiffIntent {
430    /// Analyze a specific request
431    AnalyzeRequest {
432        /// Optional request ID
433        request_id: Option<String>,
434        /// Filters to apply
435        filters: ContractDiffFilters,
436    },
437    /// Compare two contract versions
438    CompareVersions {
439        /// Path to first spec
440        spec1_path: Option<String>,
441        /// Path to second spec
442        spec2_path: Option<String>,
443        /// Filters to apply
444        filters: ContractDiffFilters,
445    },
446    /// Summarize contract drift
447    SummarizeDrift {
448        /// Filters to apply
449        filters: ContractDiffFilters,
450    },
451    /// Find breaking changes
452    FindBreakingChanges {
453        /// Filters to apply
454        filters: ContractDiffFilters,
455    },
456    /// Unknown intent
457    Unknown,
458}
459
460/// Filters for contract diff queries
461#[derive(Debug, Clone, Serialize, Deserialize, Default)]
462pub struct ContractDiffFilters {
463    /// Show only breaking changes
464    pub breaking_only: bool,
465    /// Filter by endpoint pattern
466    pub endpoint_filter: Option<String>,
467}
468
469/// Result of a contract diff query
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct ContractDiffQueryResult {
472    /// Detected intent
473    pub intent: ContractDiffIntent,
474    /// Contract diff result (if analysis was performed)
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub result: Option<ContractDiffResult>,
477    /// Human-readable summary
478    pub summary: String,
479    /// Breaking changes found
480    pub breaking_changes: Vec<BreakingChange>,
481    /// Link to Contract Diff Viewer page
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub link_to_viewer: Option<String>,
484}
485
486/// Breaking change information
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct BreakingChange {
489    /// Path/endpoint affected
490    pub path: String,
491    /// HTTP method (if applicable)
492    pub method: Option<String>,
493    /// Description of the breaking change
494    pub description: String,
495    /// Impact assessment
496    pub impact: String,
497}