mockforge_core/
spec_parser.rs

1//! Unified specification parser and validator
2//!
3//! This module provides a unified interface for parsing and validating
4//! different API specification formats: OpenAPI (2.0/3.x), GraphQL schemas,
5//! and gRPC service definitions (protobuf).
6//!
7//! It provides consistent error handling and validation for all spec types.
8
9use crate::{Error, Result};
10use serde_json::Value;
11use std::path::Path;
12
13/// Supported specification formats
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SpecFormat {
16    /// OpenAPI 2.0 (Swagger)
17    OpenApi20,
18    /// OpenAPI 3.0.x
19    OpenApi30,
20    /// OpenAPI 3.1.x
21    OpenApi31,
22    /// GraphQL Schema Definition Language (SDL)
23    GraphQL,
24    /// Protocol Buffers (protobuf)
25    Protobuf,
26}
27
28impl SpecFormat {
29    /// Detect the format from file content
30    pub fn detect(content: &str, file_path: Option<&Path>) -> Result<Self> {
31        // First, try to detect from file extension
32        if let Some(path) = file_path {
33            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
34                match ext.to_lowercase().as_str() {
35                    "graphql" | "gql" => return Ok(Self::GraphQL),
36                    "proto" => return Ok(Self::Protobuf),
37                    _ => {}
38                }
39            }
40        }
41
42        // Helper to check if content looks like JSON (starts with { or [ after trimming)
43        let is_likely_json = |s: &str| {
44            let trimmed = s.trim();
45            trimmed.starts_with('{') || trimmed.starts_with('[')
46        };
47
48        // Helper to check if content looks like YAML (has key: value patterns, comments, etc.)
49        let is_likely_yaml = |s: &str| {
50            let trimmed = s.trim();
51            !is_likely_json(s)
52                && (trimmed.contains(":\n")
53                    || trimmed.contains(": ")
54                    || trimmed.starts_with('#')
55                    || trimmed.contains('\n'))
56        };
57
58        // Try parsing as JSON first if it looks like JSON
59        if is_likely_json(content) {
60            if let Ok(json) = serde_json::from_str::<Value>(content) {
61                // Check for OpenAPI/Swagger indicators
62                if json.get("swagger").is_some() {
63                    if let Some(swagger_version) = json.get("swagger").and_then(|v| v.as_str()) {
64                        if swagger_version.starts_with("2.") {
65                            return Ok(Self::OpenApi20);
66                        }
67                    }
68                }
69
70                if json.get("openapi").is_some() {
71                    if let Some(openapi_version) = json.get("openapi").and_then(|v| v.as_str()) {
72                        if openapi_version.starts_with("3.0") {
73                            return Ok(Self::OpenApi30);
74                        } else if openapi_version.starts_with("3.1") {
75                            return Ok(Self::OpenApi31);
76                        }
77                    }
78                }
79            }
80        }
81
82        // Try parsing as YAML (either explicitly YAML-looking, or if JSON parsing failed)
83        if is_likely_yaml(content) || !is_likely_json(content) {
84            if let Ok(yaml) = serde_yaml::from_str::<Value>(content) {
85                if yaml.get("swagger").is_some() {
86                    return Ok(Self::OpenApi20);
87                }
88                if yaml.get("openapi").is_some() {
89                    if let Some(openapi_version) = yaml.get("openapi").and_then(|v| v.as_str()) {
90                        if openapi_version.starts_with("3.0") {
91                            return Ok(Self::OpenApi30);
92                        } else if openapi_version.starts_with("3.1") {
93                            return Ok(Self::OpenApi31);
94                        }
95                    }
96                }
97            }
98        }
99
100        // Check for GraphQL syntax (type, schema, etc.)
101        let content_lower = content.trim().to_lowercase();
102        if content_lower.contains("type ")
103            && (content_lower.contains("query") || content_lower.contains("mutation"))
104        {
105            return Ok(Self::GraphQL);
106        }
107
108        // Default to trying OpenAPI 3.0 if we can't detect
109        // This allows the validator to provide better error messages
110        Err(Error::validation(format!(
111            "Could not detect specification format. \
112            Expected OpenAPI (2.0/3.x), GraphQL schema, or protobuf definition."
113        )))
114    }
115
116    /// Get a human-readable name for the format
117    pub fn display_name(&self) -> &'static str {
118        match self {
119            Self::OpenApi20 => "OpenAPI 2.0 (Swagger)",
120            Self::OpenApi30 => "OpenAPI 3.0.x",
121            Self::OpenApi31 => "OpenAPI 3.1.x",
122            Self::GraphQL => "GraphQL Schema",
123            Self::Protobuf => "Protocol Buffers",
124        }
125    }
126}
127
128/// Specification validation result with detailed errors
129#[derive(Debug, Clone)]
130pub struct ValidationResult {
131    /// Whether the spec is valid
132    pub is_valid: bool,
133    /// List of validation errors
134    pub errors: Vec<ValidationError>,
135    /// List of validation warnings
136    pub warnings: Vec<String>,
137}
138
139impl ValidationResult {
140    /// Create a successful validation result
141    pub fn success() -> Self {
142        Self {
143            is_valid: true,
144            errors: vec![],
145            warnings: vec![],
146        }
147    }
148
149    /// Create a failed validation result with errors
150    pub fn failure(errors: Vec<ValidationError>) -> Self {
151        Self {
152            is_valid: false,
153            errors,
154            warnings: vec![],
155        }
156    }
157
158    /// Add a warning
159    pub fn with_warning(mut self, warning: String) -> Self {
160        self.warnings.push(warning);
161        self
162    }
163
164    /// Add multiple warnings
165    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
166        self.warnings.extend(warnings);
167        self
168    }
169
170    /// Check if there are any errors
171    pub fn has_errors(&self) -> bool {
172        !self.errors.is_empty()
173    }
174}
175
176/// Detailed validation error
177#[derive(Debug, Clone)]
178pub struct ValidationError {
179    /// Error message
180    pub message: String,
181    /// JSON pointer to the problematic field (for JSON/YAML specs)
182    pub path: Option<String>,
183    /// Error code for programmatic handling
184    pub code: Option<String>,
185    /// Suggested fix (if available)
186    pub suggestion: Option<String>,
187}
188
189impl ValidationError {
190    /// Create a new validation error
191    pub fn new(message: String) -> Self {
192        Self {
193            message,
194            path: None,
195            code: None,
196            suggestion: None,
197        }
198    }
199
200    /// Add a JSON pointer path
201    pub fn at_path(mut self, path: String) -> Self {
202        self.path = Some(path);
203        self
204    }
205
206    /// Add an error code
207    pub fn with_code(mut self, code: String) -> Self {
208        self.code = Some(code);
209        self
210    }
211
212    /// Add a suggested fix
213    pub fn with_suggestion(mut self, suggestion: String) -> Self {
214        self.suggestion = Some(suggestion);
215        self
216    }
217}
218
219impl std::fmt::Display for ValidationError {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        write!(f, "{}", self.message)?;
222        if let Some(path) = &self.path {
223            write!(f, " (at {})", path)?;
224        }
225        if let Some(suggestion) = &self.suggestion {
226            write!(f, ". Suggestion: {}", suggestion)?;
227        }
228        Ok(())
229    }
230}
231
232/// Enhanced OpenAPI validator with support for 2.0 and 3.x
233pub struct OpenApiValidator;
234
235impl OpenApiValidator {
236    /// Validate an OpenAPI specification (2.0 or 3.x)
237    pub fn validate(spec: &Value, format: SpecFormat) -> ValidationResult {
238        let mut errors = Vec::new();
239
240        // Check basic structure
241        if !spec.is_object() {
242            return ValidationResult::failure(vec![ValidationError::new(
243                "OpenAPI specification must be a JSON object".to_string(),
244            )
245            .with_code("INVALID_ROOT".to_string())]);
246        }
247
248        // Validate based on version
249        match format {
250            SpecFormat::OpenApi20 => {
251                Self::validate_version_field(
252                    spec,
253                    "swagger",
254                    &mut errors,
255                    "/swagger",
256                    "OpenAPI 2.0",
257                );
258                Self::validate_common_sections(spec, &mut errors, "OpenAPI 2.0");
259            }
260            SpecFormat::OpenApi30 | SpecFormat::OpenApi31 => {
261                Self::validate_version_field(
262                    spec,
263                    "openapi",
264                    &mut errors,
265                    "/openapi",
266                    "OpenAPI 3.x",
267                );
268                if let Some(version) = spec.get("openapi").and_then(|v| v.as_str()) {
269                    if !version.starts_with("3.") {
270                        errors.push(
271                            ValidationError::new(format!(
272                                "Invalid OpenAPI version '{}'. Expected 3.0.x or 3.1.x",
273                                version
274                            ))
275                            .at_path("/openapi".to_string())
276                            .with_code("INVALID_VERSION".to_string())
277                            .with_suggestion(
278                                "Use 'openapi': '3.0.0' or 'openapi': '3.1.0'".to_string(),
279                            ),
280                        );
281                    }
282                }
283                Self::validate_common_sections(spec, &mut errors, "OpenAPI 3.x");
284            }
285            _ => {
286                errors.push(ValidationError::new(
287                    "Invalid format for OpenAPI validation".to_string(),
288                ));
289            }
290        }
291
292        if errors.is_empty() {
293            ValidationResult::success()
294        } else {
295            ValidationResult::failure(errors)
296        }
297    }
298
299    /// Validate version field (swagger for 2.0, openapi for 3.x)
300    fn validate_version_field(
301        spec: &Value,
302        field_name: &str,
303        errors: &mut Vec<ValidationError>,
304        path: &str,
305        spec_type: &str,
306    ) {
307        let _version = spec.get(field_name).and_then(|v| v.as_str()).ok_or_else(|| {
308            errors.push(
309                ValidationError::new(format!(
310                    "Missing '{}' field in {} spec",
311                    field_name, spec_type
312                ))
313                .at_path(path.to_string())
314                .with_code(format!("MISSING_{}_FIELD", field_name.to_uppercase()))
315                .with_suggestion(format!(
316                    "Add '{}': '{}' to the root of the specification",
317                    field_name,
318                    if field_name == "swagger" {
319                        "2.0"
320                    } else {
321                        "3.0.0 or 3.1.0"
322                    }
323                )),
324            );
325        });
326    }
327
328    /// Validate common sections shared between OpenAPI 2.0 and 3.x
329    fn validate_common_sections(spec: &Value, errors: &mut Vec<ValidationError>, spec_type: &str) {
330        // Check info section
331        let info = spec.get("info").ok_or_else(|| {
332            errors.push(
333                ValidationError::new(format!("Missing 'info' section in {} spec", spec_type))
334                    .at_path("/info".to_string())
335                    .with_code("MISSING_INFO".to_string())
336                    .with_suggestion(
337                        "Add an 'info' section with 'title' and 'version' fields".to_string(),
338                    ),
339            );
340        });
341
342        if let Ok(info) = info {
343            // Check info.title
344            if info.get("title").is_none()
345                || info.get("title").and_then(|t| t.as_str()).map(|s| s.is_empty()) == Some(true)
346            {
347                errors.push(
348                    ValidationError::new("Missing or empty 'info.title' field".to_string())
349                        .at_path("/info/title".to_string())
350                        .with_code("MISSING_TITLE".to_string())
351                        .with_suggestion("Add 'title' field to the 'info' section".to_string()),
352                );
353            }
354
355            // Check info.version
356            if info.get("version").is_none()
357                || info.get("version").and_then(|v| v.as_str()).map(|s| s.is_empty()) == Some(true)
358            {
359                errors.push(
360                    ValidationError::new("Missing or empty 'info.version' field".to_string())
361                        .at_path("/info/version".to_string())
362                        .with_code("MISSING_VERSION".to_string())
363                        .with_suggestion("Add 'version' field to the 'info' section".to_string()),
364                );
365            }
366        }
367
368        // Check paths section
369        let paths = spec.get("paths").ok_or_else(|| {
370            errors.push(
371                ValidationError::new(format!(
372                    "Missing 'paths' section in {} spec. At least one endpoint is required.",
373                    spec_type
374                ))
375                .at_path("/paths".to_string())
376                .with_code("MISSING_PATHS".to_string())
377                .with_suggestion(
378                    "Add a 'paths' section with at least one endpoint definition".to_string(),
379                ),
380            );
381        });
382
383        if let Ok(paths) = paths {
384            if !paths.is_object() {
385                errors.push(
386                    ValidationError::new("'paths' must be an object".to_string())
387                        .at_path("/paths".to_string())
388                        .with_code("INVALID_PATHS_TYPE".to_string()),
389                );
390            } else if paths.as_object().map(|m| m.is_empty()) == Some(true) {
391                errors.push(
392                    ValidationError::new(
393                        "'paths' object cannot be empty. At least one endpoint is required."
394                            .to_string(),
395                    )
396                    .at_path("/paths".to_string())
397                    .with_code("EMPTY_PATHS".to_string())
398                    .with_suggestion(
399                        "Add at least one path definition, e.g., '/users': { 'get': { ... } }"
400                            .to_string(),
401                    ),
402                );
403            }
404        }
405    }
406}
407
408/// GraphQL schema validator with detailed error reporting
409///
410/// Note: This provides basic validation. For full GraphQL schema validation,
411/// use the GraphQL crate's dedicated validator which uses async-graphql parser.
412pub struct GraphQLValidator;
413
414impl GraphQLValidator {
415    /// Validate a GraphQL schema (basic validation without async-graphql dependency)
416    ///
417    /// For detailed GraphQL validation with full parser support, use
418    /// `mockforge_graphql::GraphQLSchemaRegistry::from_sdl()` which provides
419    /// comprehensive validation.
420    pub fn validate(content: &str) -> ValidationResult {
421        let errors = Vec::new();
422        let mut warnings = Vec::new();
423
424        // Check that content is not empty
425        if content.trim().is_empty() {
426            return ValidationResult::failure(vec![ValidationError::new(
427                "GraphQL schema cannot be empty".to_string(),
428            )
429            .with_code("EMPTY_SCHEMA".to_string())]);
430        }
431
432        // Basic syntax checks without requiring async-graphql parser
433        // These are heuristics that catch common issues
434        let content_trimmed = content.trim();
435
436        // Check for basic GraphQL keywords
437        if !content_trimmed.contains("type") && !content_trimmed.contains("schema") {
438            warnings
439                .push("Schema doesn't appear to contain any GraphQL type definitions.".to_string());
440        }
441
442        // Check for Query type
443        Self::check_schema_completeness(content, &mut warnings);
444
445        // Basic validation passed - for full validation, use GraphQL crate
446        if errors.is_empty() {
447            if warnings.is_empty() {
448                ValidationResult::success()
449            } else {
450                ValidationResult::success().with_warnings(warnings)
451            }
452        } else {
453            ValidationResult::failure(errors)
454        }
455    }
456
457    /// Check for completeness issues (warnings)
458    fn check_schema_completeness(content: &str, warnings: &mut Vec<String>) {
459        // Check for Query type
460        if !content.contains("type Query") && !content.contains("extend type Query") {
461            warnings.push(
462                "Schema does not define a Query type. GraphQL schemas typically need a Query type."
463                    .to_string(),
464            );
465        }
466
467        // Check for at least one field definition
468        if !content.contains(":") && !content.contains("{") {
469            warnings.push("Schema appears to be empty or incomplete.".to_string());
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_detect_openapi_30_json() {
480        let content =
481            r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
482        let format = SpecFormat::detect(content, None).unwrap();
483        assert_eq!(format, SpecFormat::OpenApi30);
484    }
485
486    #[test]
487    fn test_detect_openapi_31_yaml() {
488        let content = "openapi: 3.1.0\ninfo:\n  title: Test\n  version: 1.0.0\npaths: {}";
489        let format = SpecFormat::detect(content, None).unwrap();
490        assert_eq!(format, SpecFormat::OpenApi31);
491    }
492
493    #[test]
494    fn test_detect_swagger_20() {
495        let content =
496            r#"{"swagger": "2.0", "info": {"title": "Test", "version": "1.0.0"}, "paths": {}}"#;
497        let format = SpecFormat::detect(content, None).unwrap();
498        assert_eq!(format, SpecFormat::OpenApi20);
499    }
500
501    #[test]
502    fn test_detect_graphql_from_extension() {
503        let path = std::path::Path::new("schema.graphql");
504        let content = "type Query { users: [User] }";
505        let format = SpecFormat::detect(content, Some(path)).unwrap();
506        assert_eq!(format, SpecFormat::GraphQL);
507    }
508
509    #[test]
510    fn test_detect_graphql_from_content() {
511        let content = "type Query { users: [User!]! } type User { id: ID! name: String }";
512        let format = SpecFormat::detect(content, None).unwrap();
513        assert_eq!(format, SpecFormat::GraphQL);
514    }
515
516    #[test]
517    fn test_validate_openapi_30_valid() {
518        let spec = serde_json::json!({
519            "openapi": "3.0.0",
520            "info": {
521                "title": "Test API",
522                "version": "1.0.0"
523            },
524            "paths": {
525                "/users": {
526                    "get": {
527                        "responses": {
528                            "200": {
529                                "description": "Success"
530                            }
531                        }
532                    }
533                }
534            }
535        });
536        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
537        assert!(result.is_valid);
538        assert!(!result.has_errors());
539    }
540
541    #[test]
542    fn test_validate_openapi_30_missing_info() {
543        let spec = serde_json::json!({
544            "openapi": "3.0.0",
545            "paths": {}
546        });
547        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
548        assert!(!result.is_valid);
549        assert!(result.has_errors());
550        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_INFO")));
551    }
552
553    #[test]
554    fn test_validate_openapi_30_empty_paths() {
555        let spec = serde_json::json!({
556            "openapi": "3.0.0",
557            "info": {
558                "title": "Test",
559                "version": "1.0.0"
560            },
561            "paths": {}
562        });
563        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
564        assert!(!result.is_valid);
565        assert!(result.has_errors());
566        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_PATHS")));
567    }
568
569    #[test]
570    fn test_validate_swagger_20_valid() {
571        let spec = serde_json::json!({
572            "swagger": "2.0",
573            "info": {
574                "title": "Test API",
575                "version": "1.0.0"
576            },
577            "paths": {
578                "/users": {
579                    "get": {
580                        "responses": {
581                            "200": {
582                                "description": "Success"
583                            }
584                        }
585                    }
586                }
587            }
588        });
589        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
590        assert!(result.is_valid);
591    }
592
593    #[test]
594    fn test_validate_graphql_valid() {
595        let schema = "type Query { users: [User!]! } type User { id: ID! name: String }";
596        let result = GraphQLValidator::validate(schema);
597        assert!(result.is_valid);
598        assert!(!result.has_errors());
599    }
600
601    #[test]
602    fn test_validate_graphql_invalid() {
603        let schema = "type Query { users: [User!]! }"; // Missing User type
604        let result = GraphQLValidator::validate(schema);
605        // The parser might still accept this as valid syntax even if incomplete
606        // So we check if it at least parsed
607        assert!(!result.has_errors() || result.errors.len() > 0);
608    }
609
610    #[test]
611    fn test_validate_openapi_30_missing_title() {
612        let spec = serde_json::json!({
613            "openapi": "3.0.0",
614            "info": {
615                "version": "1.0.0"
616            },
617            "paths": {
618                "/users": {
619                    "get": {
620                        "responses": {
621                            "200": {"description": "Success"}
622                        }
623                    }
624                }
625            }
626        });
627        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
628        assert!(!result.is_valid);
629        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_TITLE")));
630    }
631
632    #[test]
633    fn test_validate_openapi_30_missing_version() {
634        let spec = serde_json::json!({
635            "openapi": "3.0.0",
636            "info": {
637                "title": "Test API"
638            },
639            "paths": {
640                "/users": {
641                    "get": {
642                        "responses": {
643                            "200": {"description": "Success"}
644                        }
645                    }
646                }
647            }
648        });
649        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
650        assert!(!result.is_valid);
651        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_VERSION")));
652    }
653
654    #[test]
655    fn test_validate_swagger_20_missing_paths() {
656        let spec = serde_json::json!({
657            "swagger": "2.0",
658            "info": {
659                "title": "Test API",
660                "version": "1.0.0"
661            }
662        });
663        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi20);
664        assert!(!result.is_valid);
665        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_PATHS")));
666    }
667
668    #[test]
669    fn test_validate_error_with_suggestion() {
670        let spec = serde_json::json!({
671            "openapi": "3.0.0",
672            "paths": {}
673        });
674        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
675        assert!(!result.is_valid);
676        // Check that errors have suggestions
677        let errors_with_suggestions: Vec<_> =
678            result.errors.iter().filter(|e| e.suggestion.is_some()).collect();
679        assert!(!errors_with_suggestions.is_empty());
680    }
681
682    #[test]
683    fn test_validate_graphql_empty() {
684        let result = GraphQLValidator::validate("");
685        assert!(!result.is_valid);
686        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("EMPTY_SCHEMA")));
687    }
688
689    #[test]
690    fn test_validate_graphql_with_warnings() {
691        let schema = "type User { id: ID! name: String }"; // No Query type
692        let result = GraphQLValidator::validate(schema);
693        // Should be valid syntax but have warnings
694        assert!(result.is_valid || !result.errors.is_empty());
695        // Should have warning about missing Query type
696        assert!(result.warnings.iter().any(|w| w.contains("Query")));
697    }
698
699    #[test]
700    fn test_spec_format_display_name() {
701        assert_eq!(SpecFormat::OpenApi20.display_name(), "OpenAPI 2.0 (Swagger)");
702        assert_eq!(SpecFormat::OpenApi30.display_name(), "OpenAPI 3.0.x");
703        assert_eq!(SpecFormat::OpenApi31.display_name(), "OpenAPI 3.1.x");
704        assert_eq!(SpecFormat::GraphQL.display_name(), "GraphQL Schema");
705        assert_eq!(SpecFormat::Protobuf.display_name(), "Protocol Buffers");
706    }
707
708    #[test]
709    fn test_validation_result_with_warnings() {
710        let result = ValidationResult::success().with_warning("Test warning".to_string());
711        assert!(result.is_valid);
712        assert_eq!(result.warnings.len(), 1);
713        assert_eq!(result.warnings[0], "Test warning");
714    }
715
716    #[test]
717    fn test_detect_yaml_with_whitespace() {
718        let content =
719            "\n\n  openapi: 3.0.0\n  info:\n    title: Test\n    version: 1.0.0\n  paths: {}";
720        let format = SpecFormat::detect(content, None).unwrap();
721        assert_eq!(format, SpecFormat::OpenApi30);
722    }
723
724    #[test]
725    fn test_detect_yaml_with_comments() {
726        let content = "# This is a YAML comment\nopenapi: 3.0.0\ninfo:\n  title: Test\n  version: 1.0.0\npaths: {}";
727        let format = SpecFormat::detect(content, None).unwrap();
728        assert_eq!(format, SpecFormat::OpenApi30);
729    }
730
731    #[test]
732    fn test_detect_yaml_with_leading_whitespace() {
733        let content =
734            "    openapi: 3.0.0\n    info:\n      title: Test\n      version: 1.0.0\n    paths: {}";
735        let format = SpecFormat::detect(content, None).unwrap();
736        assert_eq!(format, SpecFormat::OpenApi30);
737    }
738
739    #[test]
740    fn test_detect_swagger_yaml() {
741        let content = "swagger: \"2.0\"\ninfo:\n  title: Test API\n  version: 1.0.0\npaths:\n  /test:\n    get:\n      responses:\n        '200':\n          description: OK";
742        let format = SpecFormat::detect(content, None).unwrap();
743        assert_eq!(format, SpecFormat::OpenApi20);
744    }
745
746    #[test]
747    fn test_validate_common_sections_shared_logic() {
748        // Test that common validation works for both 2.0 and 3.x
749        let spec_20 = serde_json::json!({
750            "swagger": "2.0",
751            "info": {
752                "title": "Test",
753                "version": "1.0.0"
754            },
755            "paths": {
756                "/test": {
757                    "get": {
758                        "responses": {
759                            "200": {"description": "OK"}
760                        }
761                    }
762                }
763            }
764        });
765
766        let spec_30 = serde_json::json!({
767            "openapi": "3.0.0",
768            "info": {
769                "title": "Test",
770                "version": "1.0.0"
771            },
772            "paths": {
773                "/test": {
774                    "get": {
775                        "responses": {
776                            "200": {"description": "OK"}
777                        }
778                    }
779                }
780            }
781        });
782
783        let result_20 = OpenApiValidator::validate(&spec_20, SpecFormat::OpenApi20);
784        let result_30 = OpenApiValidator::validate(&spec_30, SpecFormat::OpenApi30);
785
786        assert!(result_20.is_valid);
787        assert!(result_30.is_valid);
788    }
789
790    #[test]
791    fn test_validate_version_field_extraction() {
792        let spec = serde_json::json!({
793            "info": {
794                "title": "Test",
795                "version": "1.0.0"
796            },
797            "paths": {}
798        });
799
800        // Should fail without version field
801        let result = OpenApiValidator::validate(&spec, SpecFormat::OpenApi30);
802        assert!(!result.is_valid);
803        assert!(result.errors.iter().any(|e| e.code.as_deref() == Some("MISSING_OPENAPI_FIELD")));
804    }
805}