1use super::types::{ContractDiffConfig, Mismatch, MismatchSeverity, MismatchType};
8use crate::intelligent_behavior::config::BehaviorModelConfig;
9use crate::intelligent_behavior::llm_client::LlmClient;
10use crate::intelligent_behavior::types::LlmGenerationRequest;
11use mockforge_foundation::Result;
12use mockforge_openapi::OpenApiSpec;
13pub use mockforge_foundation::contract_diff_types::{SemanticChangeType, SemanticDriftResult};
15use openapiv3;
16use serde_json::Value;
17use std::collections::HashMap;
18
19pub struct SemanticAnalyzer {
21 llm_client: Option<LlmClient>,
23 config: ContractDiffConfig,
25}
26
27impl SemanticAnalyzer {
28 pub fn new(config: ContractDiffConfig) -> Result<Self> {
30 let llm_client = if config.semantic_analysis_enabled {
31 let llm_config = BehaviorModelConfig {
32 llm_provider: config.llm_provider.clone(),
33 model: config.llm_model.clone(),
34 api_key: config.api_key.clone(),
35 api_endpoint: None,
36 temperature: 0.3, max_tokens: 3000,
38 rules: crate::intelligent_behavior::BehaviorRules::default(),
39 seed: None,
40 };
41
42 Some(LlmClient::new(llm_config))
43 } else {
44 None
45 };
46
47 Ok(Self { llm_client, config })
48 }
49
50 pub async fn analyze_semantic_drift(
56 &self,
57 before_spec: &OpenApiSpec,
58 after_spec: &OpenApiSpec,
59 endpoint_path: &str,
60 method: &str,
61 ) -> Result<Option<SemanticDriftResult>> {
62 if !self.config.semantic_analysis_enabled {
63 return Ok(None);
64 }
65
66 let before_schema = self.extract_endpoint_schema(before_spec, endpoint_path, method);
68 let after_schema = self.extract_endpoint_schema(after_spec, endpoint_path, method);
69
70 if before_schema.is_none() || after_schema.is_none() {
71 return Ok(None);
72 }
73
74 let before = before_schema.unwrap();
75 let after = after_schema.unwrap();
76
77 let rule_based_changes = self.detect_rule_based_changes(&before, &after);
79
80 if let Some(ref llm_client) = self.llm_client {
82 let llm_result = self
83 .analyze_with_llm(llm_client, &before, &after, endpoint_path, method)
84 .await?;
85
86 Ok(Some(self.combine_results(rule_based_changes, llm_result, before, after)))
88 } else {
89 if rule_based_changes.is_empty() {
91 return Ok(None);
92 }
93
94 let change_type = self.determine_change_type(&rule_based_changes);
96 let semantic_confidence = 0.6; let soft_breaking_score = self.calculate_soft_breaking_score(&rule_based_changes);
98
99 Ok(Some(SemanticDriftResult {
100 semantic_confidence,
101 soft_breaking_score,
102 change_type,
103 llm_analysis: serde_json::json!({}),
104 before_semantic_state: before,
105 after_semantic_state: after,
106 semantic_mismatches: rule_based_changes,
107 }))
108 }
109 }
110
111 fn extract_endpoint_schema(
113 &self,
114 spec: &OpenApiSpec,
115 endpoint_path: &str,
116 method: &str,
117 ) -> Option<Value> {
118 spec.spec.paths.paths.get(endpoint_path).and_then(|path_item| {
121 path_item.as_item().and_then(|item| {
122 let operation = match method.to_uppercase().as_str() {
124 "GET" => item.get.as_ref(),
125 "POST" => item.post.as_ref(),
126 "PUT" => item.put.as_ref(),
127 "DELETE" => item.delete.as_ref(),
128 "PATCH" => item.patch.as_ref(),
129 "HEAD" => item.head.as_ref(),
130 "OPTIONS" => item.options.as_ref(),
131 "TRACE" => item.trace.as_ref(),
132 _ => None,
133 }?;
134
135 operation.responses.responses.get(&openapiv3::StatusCode::Code(200)).and_then(
136 |resp| {
137 resp.as_item().and_then(|r| {
138 r.content.get("application/json").and_then(|media| {
139 media
140 .schema
141 .as_ref()
142 .map(|s| serde_json::to_value(s).unwrap_or_default())
143 })
144 })
145 },
146 )
147 })
148 })
149 }
150
151 fn detect_rule_based_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
153 let mut mismatches = Vec::new();
154
155 mismatches.extend(self.detect_description_changes(before, after));
157
158 mismatches.extend(self.detect_enum_narrowing(before, after));
160
161 mismatches.extend(self.detect_nullable_changes(before, after));
163
164 mismatches.extend(self.detect_error_code_changes(before, after));
166
167 mismatches
168 }
169
170 fn detect_description_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
172 let mut mismatches = Vec::new();
173
174 if let (Some(before_desc), Some(after_desc)) = (
176 before.get("description").and_then(|v| v.as_str()),
177 after.get("description").and_then(|v| v.as_str()),
178 ) {
179 if before_desc != after_desc {
180 let is_significant = self.is_description_meaning_change(before_desc, after_desc);
182
183 if is_significant {
184 mismatches.push(Mismatch {
185 mismatch_type: MismatchType::SemanticDescriptionChange,
186 path: "description".to_string(),
187 method: None,
188 expected: Some(before_desc.to_string()),
189 actual: Some(after_desc.to_string()),
190 description: format!(
191 "Description meaning changed: '{}' → '{}'",
192 before_desc, after_desc
193 ),
194 severity: MismatchSeverity::Medium,
195 confidence: 0.7,
196 context: HashMap::new(),
197 });
198 }
199 }
200 }
201
202 mismatches
203 }
204
205 fn is_description_meaning_change(&self, before: &str, after: &str) -> bool {
207 let before_words: Vec<&str> = before.split_whitespace().collect();
209 let after_words: Vec<&str> = after.split_whitespace().collect();
210
211 if before_words.is_empty() || after_words.is_empty() {
212 return true; }
214
215 let common_words: usize = before_words.iter().filter(|w| after_words.contains(w)).count();
216
217 let change_ratio =
218 1.0 - (common_words as f64 / before_words.len().max(after_words.len()) as f64);
219 change_ratio > 0.3
220 }
221
222 fn detect_enum_narrowing(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
224 let mut mismatches = Vec::new();
225
226 if let (Some(before_enum), Some(after_enum)) = (
227 before.get("enum").and_then(|v| v.as_array()),
228 after.get("enum").and_then(|v| v.as_array()),
229 ) {
230 let before_set: std::collections::HashSet<&Value> = before_enum.iter().collect();
231 let after_set: std::collections::HashSet<&Value> = after_enum.iter().collect();
232
233 let removed: Vec<_> = before_set.difference(&after_set).collect();
234
235 if !removed.is_empty() {
236 mismatches.push(Mismatch {
237 mismatch_type: MismatchType::SemanticEnumNarrowing,
238 path: "enum".to_string(),
239 method: None,
240 expected: Some(format!("{:?}", before_enum)),
241 actual: Some(format!("{:?}", after_enum)),
242 description: format!(
243 "Enum values narrowed: {} value(s) removed",
244 removed.len()
245 ),
246 severity: MismatchSeverity::High,
247 confidence: 1.0, context: HashMap::new(),
249 });
250 }
251 }
252
253 mismatches
254 }
255
256 fn detect_nullable_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
258 let mut mismatches = Vec::new();
259
260 let before_nullable = before.get("nullable").and_then(|v| v.as_bool()).unwrap_or(false);
262 let after_nullable = after.get("nullable").and_then(|v| v.as_bool()).unwrap_or(false);
263
264 if before_nullable && !after_nullable {
265 let is_hidden = after.get("oneOf").is_some() || after.get("anyOf").is_some();
267
268 if is_hidden {
269 mismatches.push(Mismatch {
270 mismatch_type: MismatchType::SemanticNullabilityChange,
271 path: "nullable".to_string(),
272 method: None,
273 expected: Some("nullable: true".to_string()),
274 actual: Some("nullable: false (hidden behind oneOf/anyOf)".to_string()),
275 description:
276 "Field became non-nullable but change is hidden behind oneOf/anyOf"
277 .to_string(),
278 severity: MismatchSeverity::High,
279 confidence: 0.8,
280 context: HashMap::new(),
281 });
282 }
283 }
284
285 mismatches
286 }
287
288 fn detect_error_code_changes(&self, before: &Value, after: &Value) -> Vec<Mismatch> {
293 let mut mismatches = Vec::new();
294
295 let before_codes = Self::extract_error_status_codes(before);
296 let after_codes = Self::extract_error_status_codes(after);
297
298 let removed: Vec<&String> =
299 before_codes.iter().filter(|c| !after_codes.contains(*c)).collect();
300
301 if !removed.is_empty() {
302 mismatches.push(Mismatch {
303 mismatch_type: MismatchType::SemanticErrorCodeRemoved,
304 path: "responses".to_string(),
305 method: None,
306 expected: Some(format!("{:?}", before_codes)),
307 actual: Some(format!("{:?}", after_codes)),
308 description: format!(
309 "Error status code(s) removed: {}",
310 removed.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", ")
311 ),
312 severity: MismatchSeverity::High,
313 confidence: 1.0,
314 context: HashMap::new(),
315 });
316 }
317
318 mismatches
319 }
320
321 fn extract_error_status_codes(schema: &Value) -> Vec<String> {
323 let mut codes = Vec::new();
324 if let Some(responses) = schema.get("responses").and_then(|v| v.as_object()) {
325 for key in responses.keys() {
326 if let Some(first_char) = key.chars().next() {
328 if (first_char == '4' || first_char == '5')
329 && key.len() == 3
330 && key.chars().all(|c| c.is_ascii_digit())
331 {
332 codes.push(key.clone());
333 }
334 }
335 }
336 }
337 codes.sort();
338 codes
339 }
340
341 async fn analyze_with_llm(
343 &self,
344 llm_client: &LlmClient,
345 before: &Value,
346 after: &Value,
347 endpoint_path: &str,
348 method: &str,
349 ) -> Result<Value> {
350 let prompt = self.build_semantic_analysis_prompt(before, after, endpoint_path, method);
351
352 let request = LlmGenerationRequest::new(self.get_system_prompt(), prompt)
353 .with_temperature(0.3)
354 .with_max_tokens(3000);
355
356 let response = llm_client.generate(&request).await?;
357
358 let analysis = response
360 .get("analysis")
361 .and_then(|v| v.as_str())
362 .map(|s| s.to_string())
363 .unwrap_or_else(|| serde_json::to_string(&response).unwrap_or_default());
364
365 let confidence = response.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.5);
366
367 let soft_breaking_score =
368 response.get("soft_breaking_score").and_then(|v| v.as_f64()).unwrap_or(0.5);
369
370 Ok(serde_json::json!({
371 "analysis": analysis,
372 "confidence": confidence,
373 "soft_breaking_score": soft_breaking_score
374 }))
375 }
376
377 fn build_semantic_analysis_prompt(
379 &self,
380 before: &Value,
381 after: &Value,
382 endpoint_path: &str,
383 method: &str,
384 ) -> String {
385 format!(
386 r#"Analyze the semantic differences between these two API contract schemas for endpoint {} {}.
387
388Before schema:
389{}
390
391After schema:
392{}
393
394Please identify:
3951. Any changes in meaning or semantics (not just structural changes)
3962. Description changes that alter the intended behavior
3973. Enum narrowing or constraint tightening
3984. Nullable changes that might break clients
3995. Error code removals
4006. Any "soft-breaking" changes that won't cause immediate failures but will cause issues
401
402Provide your analysis in JSON format with:
403- semantic_confidence: 0.0-1.0
404- soft_breaking_score: 0.0-1.0
405- change_type: one of the semantic change types
406- reasoning: detailed explanation
407- detected_changes: array of specific changes found"#,
408 method,
409 endpoint_path,
410 serde_json::to_string_pretty(before).unwrap_or_default(),
411 serde_json::to_string_pretty(after).unwrap_or_default()
412 )
413 }
414
415 fn get_system_prompt(&self) -> String {
417 "You are an expert API contract analyst specializing in detecting semantic drift and soft-breaking changes in API contracts. Your analysis helps teams understand when API changes might break clients even if they're not structurally breaking.".to_string()
418 }
419
420 fn combine_results(
422 &self,
423 rule_based: Vec<Mismatch>,
424 llm_result: Value,
425 before: Value,
426 after: Value,
427 ) -> SemanticDriftResult {
428 let semantic_confidence =
429 llm_result.get("semantic_confidence").and_then(|v| v.as_f64()).unwrap_or(0.7);
430
431 let soft_breaking_score =
432 llm_result.get("soft_breaking_score").and_then(|v| v.as_f64()).unwrap_or(0.5);
433
434 let change_type_str = llm_result
435 .get("change_type")
436 .and_then(|v| v.as_str())
437 .unwrap_or("meaning_shift");
438
439 let change_type = match change_type_str {
440 "description_change" => SemanticChangeType::DescriptionChange,
441 "enum_narrowing" => SemanticChangeType::EnumNarrowing,
442 "nullable_change" => SemanticChangeType::NullableChange,
443 "error_code_removed" => SemanticChangeType::ErrorCodeRemoved,
444 "semantic_constraint_change" => SemanticChangeType::SemanticConstraintChange,
445 "soft_breaking_change" => SemanticChangeType::SoftBreakingChange,
446 _ => SemanticChangeType::MeaningShift,
447 };
448
449 let semantic_mismatches = rule_based;
451
452 SemanticDriftResult {
453 semantic_confidence,
454 soft_breaking_score,
455 change_type,
456 llm_analysis: llm_result,
457 before_semantic_state: before,
458 after_semantic_state: after,
459 semantic_mismatches,
460 }
461 }
462
463 fn determine_change_type(&self, mismatches: &[Mismatch]) -> SemanticChangeType {
465 for mismatch in mismatches {
466 match mismatch.mismatch_type {
467 MismatchType::SemanticDescriptionChange => {
468 return SemanticChangeType::DescriptionChange
469 }
470 MismatchType::SemanticEnumNarrowing => return SemanticChangeType::EnumNarrowing,
471 MismatchType::SemanticNullabilityChange => {
472 return SemanticChangeType::NullableChange
473 }
474 MismatchType::SemanticErrorCodeRemoved => {
475 return SemanticChangeType::ErrorCodeRemoved
476 }
477 _ => {}
478 }
479 }
480
481 SemanticChangeType::MeaningShift
482 }
483
484 fn calculate_soft_breaking_score(&self, mismatches: &[Mismatch]) -> f64 {
486 if mismatches.is_empty() {
487 return 0.0;
488 }
489
490 let total_score: f64 = mismatches
492 .iter()
493 .map(|m| {
494 let severity_score = match m.severity {
495 MismatchSeverity::Critical => 1.0,
496 MismatchSeverity::High => 0.8,
497 MismatchSeverity::Medium => 0.6,
498 MismatchSeverity::Low => 0.4,
499 MismatchSeverity::Info => 0.2,
500 };
501 severity_score * m.confidence
502 })
503 .sum();
504
505 (total_score / mismatches.len() as f64).min(1.0)
506 }
507}