Skip to main content

mockforge_bench/
spec_dependencies.rs

1//! Cross-spec dependency detection and configuration for multi-spec benchmarking
2//!
3//! This module provides:
4//! - Auto-detection of dependencies between specs based on schema references
5//! - Manual dependency configuration via YAML/JSON files
6//! - Topological sorting for correct execution order
7//! - Value extraction and injection between spec groups
8
9use crate::error::{BenchError, Result};
10use mockforge_core::openapi::spec::OpenApiSpec;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14
15/// Cross-spec dependency configuration (optional override)
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct SpecDependencyConfig {
18    /// Ordered list of spec groups to execute
19    #[serde(default)]
20    pub execution_order: Vec<SpecGroup>,
21    /// Disable auto-detection of dependencies
22    #[serde(default)]
23    pub disable_auto_detect: bool,
24}
25
26impl SpecDependencyConfig {
27    /// Load dependency configuration from a file (YAML or JSON)
28    pub fn from_file(path: &Path) -> Result<Self> {
29        let content = std::fs::read_to_string(path)
30            .map_err(|e| BenchError::Other(format!("Failed to read dependency config: {}", e)))?;
31
32        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
33        match ext {
34            "yaml" | "yml" => serde_yaml::from_str(&content).map_err(|e| {
35                BenchError::Other(format!("Failed to parse YAML dependency config: {}", e))
36            }),
37            "json" => serde_json::from_str(&content).map_err(|e| {
38                BenchError::Other(format!("Failed to parse JSON dependency config: {}", e))
39            }),
40            _ => Err(BenchError::Other(format!(
41                "Unsupported dependency config format: {}. Use .yaml, .yml, or .json",
42                ext
43            ))),
44        }
45    }
46}
47
48/// A group of specs to execute together
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SpecGroup {
51    /// Name for this group (e.g., "infrastructure", "services")
52    pub name: String,
53    /// Spec files in this group
54    pub specs: Vec<PathBuf>,
55    /// Fields to extract from responses (JSONPath-like syntax)
56    #[serde(default)]
57    pub extract: HashMap<String, String>,
58    /// Fields to inject into next group's requests
59    #[serde(default)]
60    pub inject: HashMap<String, String>,
61}
62
63/// Detected dependency between two specs
64#[derive(Debug, Clone)]
65pub struct SpecDependency {
66    /// The spec that depends on another
67    pub dependent_spec: PathBuf,
68    /// The spec that is depended upon
69    pub dependency_spec: PathBuf,
70    /// Field name that creates the dependency (e.g., "pool_ref")
71    pub field_name: String,
72    /// Schema name being referenced (e.g., "Pool")
73    pub referenced_schema: String,
74    /// Extraction path for the dependency value
75    pub extraction_path: String,
76}
77
78/// Dependency detector for analyzing specs
79pub struct DependencyDetector {
80    /// Schemas available in each spec (spec_path -> schema_names)
81    schema_registry: HashMap<PathBuf, HashSet<String>>,
82    /// Detected dependencies
83    dependencies: Vec<SpecDependency>,
84}
85
86impl DependencyDetector {
87    /// Create a new dependency detector
88    pub fn new() -> Self {
89        Self {
90            schema_registry: HashMap::new(),
91            dependencies: Vec::new(),
92        }
93    }
94
95    /// Detect dependencies between specs by analyzing schema references
96    pub fn detect_dependencies(&mut self, specs: &[(PathBuf, OpenApiSpec)]) -> Vec<SpecDependency> {
97        // Build schema registry - collect all schemas from each spec
98        for (path, spec) in specs {
99            let schemas = self.extract_schema_names(spec);
100            self.schema_registry.insert(path.clone(), schemas);
101        }
102
103        // Analyze each spec's request bodies for references to other specs' schemas
104        for (path, spec) in specs {
105            self.analyze_spec_references(path, spec, specs);
106        }
107
108        self.dependencies.clone()
109    }
110
111    /// Extract all schema names from a spec
112    fn extract_schema_names(&self, spec: &OpenApiSpec) -> HashSet<String> {
113        let mut schemas = HashSet::new();
114
115        if let Some(components) = &spec.spec.components {
116            for (name, _) in &components.schemas {
117                schemas.insert(name.clone());
118                // Also add common variations
119                schemas.insert(name.to_lowercase());
120                schemas.insert(to_snake_case(name));
121            }
122        }
123
124        schemas
125    }
126
127    /// Analyze a spec's references to detect dependencies
128    fn analyze_spec_references(
129        &mut self,
130        current_path: &PathBuf,
131        spec: &OpenApiSpec,
132        all_specs: &[(PathBuf, OpenApiSpec)],
133    ) {
134        // Analyze request body schemas for reference patterns
135        for (path, path_item) in &spec.spec.paths.paths {
136            if let openapiv3::ReferenceOr::Item(item) = path_item {
137                // Check POST operations (most common for creating resources with refs)
138                if let Some(op) = &item.post {
139                    self.analyze_operation_refs(current_path, op, all_specs, path);
140                }
141                if let Some(op) = &item.put {
142                    self.analyze_operation_refs(current_path, op, all_specs, path);
143                }
144                if let Some(op) = &item.patch {
145                    self.analyze_operation_refs(current_path, op, all_specs, path);
146                }
147            }
148        }
149    }
150
151    /// Analyze operation request body for reference fields
152    fn analyze_operation_refs(
153        &mut self,
154        current_path: &PathBuf,
155        operation: &openapiv3::Operation,
156        all_specs: &[(PathBuf, OpenApiSpec)],
157        _api_path: &str,
158    ) {
159        if let Some(request_body) = &operation.request_body {
160            if let openapiv3::ReferenceOr::Item(body) = request_body {
161                // Check JSON content
162                if let Some(media_type) = body.content.get("application/json") {
163                    if let Some(schema_ref) = &media_type.schema {
164                        self.analyze_schema_for_refs(current_path, schema_ref, all_specs, "");
165                    }
166                }
167            }
168        }
169    }
170
171    /// Recursively analyze schema for reference patterns
172    fn analyze_schema_for_refs(
173        &mut self,
174        current_path: &PathBuf,
175        schema_ref: &openapiv3::ReferenceOr<openapiv3::Schema>,
176        all_specs: &[(PathBuf, OpenApiSpec)],
177        field_prefix: &str,
178    ) {
179        match schema_ref {
180            openapiv3::ReferenceOr::Item(schema) => {
181                self.analyze_schema(current_path, schema, all_specs, field_prefix);
182            }
183            openapiv3::ReferenceOr::Reference { reference } => {
184                // Could analyze $ref to other schemas here
185                let _ = reference; // Silence unused warning for now
186            }
187        }
188    }
189
190    /// Analyze schema for reference patterns (handles both Box<Schema> and Schema)
191    fn analyze_schema(
192        &mut self,
193        current_path: &PathBuf,
194        schema: &openapiv3::Schema,
195        all_specs: &[(PathBuf, OpenApiSpec)],
196        field_prefix: &str,
197    ) {
198        match &schema.schema_kind {
199            openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) => {
200                for (prop_name, prop_schema) in &obj.properties {
201                    let full_path = if field_prefix.is_empty() {
202                        prop_name.clone()
203                    } else {
204                        format!("{}.{}", field_prefix, prop_name)
205                    };
206
207                    // Check for reference patterns in field names
208                    if let Some(dep) = self.detect_ref_field(current_path, prop_name, all_specs) {
209                        self.dependencies.push(SpecDependency {
210                            dependent_spec: current_path.clone(),
211                            dependency_spec: dep.0,
212                            field_name: prop_name.clone(),
213                            referenced_schema: dep.1,
214                            extraction_path: format!("$.{}", full_path),
215                        });
216                    }
217
218                    // Recursively check nested schemas
219                    self.analyze_boxed_schema_ref(current_path, prop_schema, all_specs, &full_path);
220                }
221            }
222            openapiv3::SchemaKind::AllOf { all_of } => {
223                for sub_schema in all_of {
224                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
225                }
226            }
227            openapiv3::SchemaKind::OneOf { one_of } => {
228                for sub_schema in one_of {
229                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
230                }
231            }
232            openapiv3::SchemaKind::AnyOf { any_of } => {
233                for sub_schema in any_of {
234                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
235                }
236            }
237            _ => {}
238        }
239    }
240
241    /// Handle ReferenceOr<Box<Schema>> which is used in object properties
242    fn analyze_boxed_schema_ref(
243        &mut self,
244        current_path: &PathBuf,
245        schema_ref: &openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
246        all_specs: &[(PathBuf, OpenApiSpec)],
247        field_prefix: &str,
248    ) {
249        match schema_ref {
250            openapiv3::ReferenceOr::Item(boxed_schema) => {
251                self.analyze_schema(current_path, boxed_schema.as_ref(), all_specs, field_prefix);
252            }
253            openapiv3::ReferenceOr::Reference { reference } => {
254                let _ = reference; // Could analyze $ref here
255            }
256        }
257    }
258
259    /// Detect if a field name references another spec's schema
260    fn detect_ref_field(
261        &self,
262        current_path: &PathBuf,
263        field_name: &str,
264        all_specs: &[(PathBuf, OpenApiSpec)],
265    ) -> Option<(PathBuf, String)> {
266        // Common patterns for reference fields
267        let ref_patterns = [
268            ("_ref", ""),       // pool_ref -> Pool
269            ("_id", ""),        // pool_id -> Pool
270            ("Id", ""),         // poolId -> pool
271            ("_uuid", ""),      // pool_uuid -> Pool
272            ("Uuid", ""),       // poolUuid -> pool
273            ("_reference", ""), // pool_reference -> Pool
274        ];
275
276        for (suffix, _) in ref_patterns.iter() {
277            if field_name.ends_with(suffix) {
278                // Extract the schema name from the field
279                let schema_base = field_name.trim_end_matches(suffix).trim_end_matches('_');
280
281                // Search for this schema in other specs
282                for (other_path, _) in all_specs {
283                    if other_path == current_path {
284                        continue;
285                    }
286
287                    if let Some(schemas) = self.schema_registry.get(other_path) {
288                        // Check various name formats
289                        let schema_pascal = to_pascal_case(schema_base);
290                        let schema_lower = schema_base.to_lowercase();
291
292                        for schema_name in schemas {
293                            if schema_name == &schema_pascal
294                                || schema_name == &schema_lower
295                                || schema_name.to_lowercase() == schema_lower
296                            {
297                                return Some((other_path.clone(), schema_name.clone()));
298                            }
299                        }
300                    }
301                }
302            }
303        }
304
305        None
306    }
307}
308
309impl Default for DependencyDetector {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315/// Topologically sort specs based on dependencies
316pub fn topological_sort(
317    specs: &[(PathBuf, OpenApiSpec)],
318    dependencies: &[SpecDependency],
319) -> Result<Vec<PathBuf>> {
320    let spec_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
321
322    // Build adjacency list (dependency -> dependent)
323    let mut adj: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
324    let mut in_degree: HashMap<PathBuf, usize> = HashMap::new();
325
326    for path in &spec_paths {
327        adj.insert(path.clone(), Vec::new());
328        in_degree.insert(path.clone(), 0);
329    }
330
331    for dep in dependencies {
332        adj.entry(dep.dependency_spec.clone())
333            .or_default()
334            .push(dep.dependent_spec.clone());
335        *in_degree.entry(dep.dependent_spec.clone()).or_insert(0) += 1;
336    }
337
338    // Kahn's algorithm
339    let mut queue: Vec<PathBuf> = in_degree
340        .iter()
341        .filter(|(_, &deg)| deg == 0)
342        .map(|(path, _)| path.clone())
343        .collect();
344
345    let mut result = Vec::new();
346
347    while let Some(path) = queue.pop() {
348        result.push(path.clone());
349
350        if let Some(dependents) = adj.get(&path) {
351            for dependent in dependents {
352                if let Some(deg) = in_degree.get_mut(dependent) {
353                    *deg -= 1;
354                    if *deg == 0 {
355                        queue.push(dependent.clone());
356                    }
357                }
358            }
359        }
360    }
361
362    if result.len() != spec_paths.len() {
363        return Err(BenchError::Other("Circular dependency detected between specs".to_string()));
364    }
365
366    Ok(result)
367}
368
369/// Convert string to snake_case
370fn to_snake_case(s: &str) -> String {
371    let mut result = String::new();
372    for (i, c) in s.chars().enumerate() {
373        if c.is_uppercase() {
374            if i > 0 {
375                result.push('_');
376            }
377            result.push(c.to_lowercase().next().unwrap());
378        } else {
379            result.push(c);
380        }
381    }
382    result
383}
384
385/// Convert string to PascalCase
386fn to_pascal_case(s: &str) -> String {
387    let mut result = String::new();
388    let mut capitalize_next = true;
389
390    for c in s.chars() {
391        if c == '_' || c == '-' {
392            capitalize_next = true;
393        } else if capitalize_next {
394            result.push(c.to_uppercase().next().unwrap());
395            capitalize_next = false;
396        } else {
397            result.push(c);
398        }
399    }
400
401    result
402}
403
404/// Extracted values from spec execution for passing to dependent specs
405#[derive(Debug, Clone, Default)]
406pub struct ExtractedValues {
407    /// Values extracted by variable name
408    pub values: HashMap<String, serde_json::Value>,
409}
410
411impl ExtractedValues {
412    /// Create new empty extracted values
413    pub fn new() -> Self {
414        Self::default()
415    }
416
417    /// Set a value
418    pub fn set(&mut self, key: String, value: serde_json::Value) {
419        self.values.insert(key, value);
420    }
421
422    /// Get a value
423    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
424        self.values.get(key)
425    }
426
427    /// Merge values from another ExtractedValues
428    pub fn merge(&mut self, other: &ExtractedValues) {
429        for (key, value) in &other.values {
430            self.values.insert(key.clone(), value.clone());
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_to_snake_case() {
441        assert_eq!(to_snake_case("PascalCase"), "pascal_case");
442        assert_eq!(to_snake_case("camelCase"), "camel_case");
443        assert_eq!(to_snake_case("Pool"), "pool");
444        assert_eq!(to_snake_case("VirtualService"), "virtual_service");
445    }
446
447    #[test]
448    fn test_to_pascal_case() {
449        assert_eq!(to_pascal_case("snake_case"), "SnakeCase");
450        assert_eq!(to_pascal_case("pool"), "Pool");
451        assert_eq!(to_pascal_case("virtual_service"), "VirtualService");
452    }
453
454    #[test]
455    fn test_extracted_values() {
456        let mut values = ExtractedValues::new();
457        values.set("pool_id".to_string(), serde_json::json!("abc123"));
458        values.set("name".to_string(), serde_json::json!("test-pool"));
459
460        assert_eq!(values.get("pool_id"), Some(&serde_json::json!("abc123")));
461        assert_eq!(values.get("missing"), None);
462    }
463
464    #[test]
465    fn test_spec_dependency_config_default() {
466        let config = SpecDependencyConfig::default();
467        assert!(config.execution_order.is_empty());
468        assert!(!config.disable_auto_detect);
469    }
470}