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