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(openapiv3::ReferenceOr::Item(body)) = &operation.request_body {
160            // Check JSON content
161            if let Some(media_type) = body.content.get("application/json") {
162                if let Some(schema_ref) = &media_type.schema {
163                    self.analyze_schema_for_refs(current_path, schema_ref, all_specs, "");
164                }
165            }
166        }
167    }
168
169    /// Recursively analyze schema for reference patterns
170    fn analyze_schema_for_refs(
171        &mut self,
172        current_path: &PathBuf,
173        schema_ref: &openapiv3::ReferenceOr<openapiv3::Schema>,
174        all_specs: &[(PathBuf, OpenApiSpec)],
175        field_prefix: &str,
176    ) {
177        match schema_ref {
178            openapiv3::ReferenceOr::Item(schema) => {
179                self.analyze_schema(current_path, schema, all_specs, field_prefix);
180            }
181            openapiv3::ReferenceOr::Reference { reference } => {
182                self.analyze_reference(current_path, reference, all_specs, field_prefix);
183            }
184        }
185    }
186
187    /// Analyze schema for reference patterns (handles both Box<Schema> and Schema)
188    fn analyze_schema(
189        &mut self,
190        current_path: &PathBuf,
191        schema: &openapiv3::Schema,
192        all_specs: &[(PathBuf, OpenApiSpec)],
193        field_prefix: &str,
194    ) {
195        match &schema.schema_kind {
196            openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) => {
197                for (prop_name, prop_schema) in &obj.properties {
198                    let full_path = if field_prefix.is_empty() {
199                        prop_name.clone()
200                    } else {
201                        format!("{}.{}", field_prefix, prop_name)
202                    };
203
204                    // Check for reference patterns in field names
205                    if let Some(dep) = self.detect_ref_field(current_path, prop_name, all_specs) {
206                        self.dependencies.push(SpecDependency {
207                            dependent_spec: current_path.clone(),
208                            dependency_spec: dep.0,
209                            field_name: prop_name.clone(),
210                            referenced_schema: dep.1,
211                            extraction_path: format!("$.{}", full_path),
212                        });
213                    }
214
215                    // Recursively check nested schemas
216                    self.analyze_boxed_schema_ref(current_path, prop_schema, all_specs, &full_path);
217                }
218            }
219            openapiv3::SchemaKind::AllOf { all_of } => {
220                for sub_schema in all_of {
221                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
222                }
223            }
224            openapiv3::SchemaKind::OneOf { one_of } => {
225                for sub_schema in one_of {
226                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
227                }
228            }
229            openapiv3::SchemaKind::AnyOf { any_of } => {
230                for sub_schema in any_of {
231                    self.analyze_schema_for_refs(current_path, sub_schema, all_specs, field_prefix);
232                }
233            }
234            _ => {}
235        }
236    }
237
238    /// Handle ReferenceOr<Box<Schema>> which is used in object properties
239    fn analyze_boxed_schema_ref(
240        &mut self,
241        current_path: &PathBuf,
242        schema_ref: &openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
243        all_specs: &[(PathBuf, OpenApiSpec)],
244        field_prefix: &str,
245    ) {
246        match schema_ref {
247            openapiv3::ReferenceOr::Item(boxed_schema) => {
248                self.analyze_schema(current_path, boxed_schema.as_ref(), all_specs, field_prefix);
249            }
250            openapiv3::ReferenceOr::Reference { reference } => {
251                self.analyze_reference(current_path, reference, all_specs, field_prefix);
252            }
253        }
254    }
255
256    /// Analyze a `$ref` string to detect cross-spec dependencies
257    fn analyze_reference(
258        &mut self,
259        current_path: &PathBuf,
260        reference: &str,
261        all_specs: &[(PathBuf, OpenApiSpec)],
262        field_prefix: &str,
263    ) {
264        // Handle external file references like "./other-spec.yaml#/components/schemas/Foo"
265        if let Some(hash_pos) = reference.find('#') {
266            let file_part = &reference[..hash_pos];
267            let json_pointer = &reference[hash_pos + 1..];
268
269            if !file_part.is_empty() {
270                // External reference — resolve relative to current spec
271                if let Some(parent) = current_path.parent() {
272                    let resolved = parent.join(file_part);
273                    // Extract the schema name from the JSON pointer (last segment)
274                    let schema_name =
275                        json_pointer.rsplit('/').next().unwrap_or(json_pointer).to_string();
276
277                    // Check if the referenced file is among our known specs
278                    for (other_path, _) in all_specs {
279                        if other_path == current_path {
280                            continue;
281                        }
282                        // Compare by file name since resolved paths may differ in canonicalization
283                        let resolved_name = resolved.file_name();
284                        let other_name = other_path.file_name();
285                        if resolved_name.is_some() && resolved_name == other_name {
286                            self.dependencies.push(SpecDependency {
287                                dependent_spec: current_path.clone(),
288                                dependency_spec: other_path.clone(),
289                                field_name: format!("$ref:{}", reference),
290                                referenced_schema: schema_name.clone(),
291                                extraction_path: format!("$.{}", field_prefix),
292                            });
293                            return;
294                        }
295                    }
296                }
297            } else {
298                // Local reference like "#/components/schemas/Foo" — resolve within same spec
299                let schema_name = json_pointer.rsplit('/').next().unwrap_or(json_pointer);
300
301                // Find the referenced schema in the current spec and recurse into it
302                for (spec_path, spec) in all_specs {
303                    if spec_path == current_path {
304                        if let Some(components) = &spec.spec.components {
305                            if let Some(openapiv3::ReferenceOr::Item(schema)) =
306                                components.schemas.get(schema_name)
307                            {
308                                self.analyze_schema(
309                                    current_path,
310                                    schema,
311                                    all_specs,
312                                    &format!("{}.{}", field_prefix, schema_name),
313                                );
314                            }
315                        }
316                        break;
317                    }
318                }
319            }
320        }
321    }
322
323    /// Detect if a field name references another spec's schema
324    fn detect_ref_field(
325        &self,
326        current_path: &PathBuf,
327        field_name: &str,
328        all_specs: &[(PathBuf, OpenApiSpec)],
329    ) -> Option<(PathBuf, String)> {
330        // Common patterns for reference fields
331        let ref_patterns = [
332            ("_ref", ""),       // pool_ref -> Pool
333            ("_id", ""),        // pool_id -> Pool
334            ("Id", ""),         // poolId -> pool
335            ("_uuid", ""),      // pool_uuid -> Pool
336            ("Uuid", ""),       // poolUuid -> pool
337            ("_reference", ""), // pool_reference -> Pool
338        ];
339
340        for (suffix, _) in ref_patterns.iter() {
341            if field_name.ends_with(suffix) {
342                // Extract the schema name from the field
343                let schema_base = field_name.trim_end_matches(suffix).trim_end_matches('_');
344
345                // Search for this schema in other specs
346                for (other_path, _) in all_specs {
347                    if other_path == current_path {
348                        continue;
349                    }
350
351                    if let Some(schemas) = self.schema_registry.get(other_path) {
352                        // Check various name formats
353                        let schema_pascal = to_pascal_case(schema_base);
354                        let schema_lower = schema_base.to_lowercase();
355
356                        for schema_name in schemas {
357                            if schema_name == &schema_pascal
358                                || schema_name == &schema_lower
359                                || schema_name.to_lowercase() == schema_lower
360                            {
361                                return Some((other_path.clone(), schema_name.clone()));
362                            }
363                        }
364                    }
365                }
366            }
367        }
368
369        None
370    }
371}
372
373impl Default for DependencyDetector {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379/// Topologically sort specs based on dependencies
380pub fn topological_sort(
381    specs: &[(PathBuf, OpenApiSpec)],
382    dependencies: &[SpecDependency],
383) -> Result<Vec<PathBuf>> {
384    let spec_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
385
386    // Build adjacency list (dependency -> dependent)
387    let mut adj: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
388    let mut in_degree: HashMap<PathBuf, usize> = HashMap::new();
389
390    for path in &spec_paths {
391        adj.insert(path.clone(), Vec::new());
392        in_degree.insert(path.clone(), 0);
393    }
394
395    for dep in dependencies {
396        adj.entry(dep.dependency_spec.clone())
397            .or_default()
398            .push(dep.dependent_spec.clone());
399        *in_degree.entry(dep.dependent_spec.clone()).or_insert(0) += 1;
400    }
401
402    // Kahn's algorithm
403    let mut queue: Vec<PathBuf> = in_degree
404        .iter()
405        .filter(|(_, &deg)| deg == 0)
406        .map(|(path, _)| path.clone())
407        .collect();
408
409    let mut result = Vec::new();
410
411    while let Some(path) = queue.pop() {
412        result.push(path.clone());
413
414        if let Some(dependents) = adj.get(&path) {
415            for dependent in dependents {
416                if let Some(deg) = in_degree.get_mut(dependent) {
417                    *deg -= 1;
418                    if *deg == 0 {
419                        queue.push(dependent.clone());
420                    }
421                }
422            }
423        }
424    }
425
426    if result.len() != spec_paths.len() {
427        return Err(BenchError::Other("Circular dependency detected between specs".to_string()));
428    }
429
430    Ok(result)
431}
432
433/// Convert string to snake_case
434fn to_snake_case(s: &str) -> String {
435    let mut result = String::new();
436    for (i, c) in s.chars().enumerate() {
437        if c.is_uppercase() {
438            if i > 0 {
439                result.push('_');
440            }
441            result.push(c.to_lowercase().next().unwrap());
442        } else {
443            result.push(c);
444        }
445    }
446    result
447}
448
449/// Convert string to PascalCase
450fn to_pascal_case(s: &str) -> String {
451    let mut result = String::new();
452    let mut capitalize_next = true;
453
454    for c in s.chars() {
455        if c == '_' || c == '-' {
456            capitalize_next = true;
457        } else if capitalize_next {
458            result.push(c.to_uppercase().next().unwrap());
459            capitalize_next = false;
460        } else {
461            result.push(c);
462        }
463    }
464
465    result
466}
467
468/// Extracted values from spec execution for passing to dependent specs
469#[derive(Debug, Clone, Default)]
470pub struct ExtractedValues {
471    /// Values extracted by variable name
472    pub values: HashMap<String, serde_json::Value>,
473}
474
475impl ExtractedValues {
476    /// Create new empty extracted values
477    pub fn new() -> Self {
478        Self::default()
479    }
480
481    /// Set a value
482    pub fn set(&mut self, key: String, value: serde_json::Value) {
483        self.values.insert(key, value);
484    }
485
486    /// Get a value
487    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
488        self.values.get(key)
489    }
490
491    /// Merge values from another ExtractedValues
492    pub fn merge(&mut self, other: &ExtractedValues) {
493        for (key, value) in &other.values {
494            self.values.insert(key.clone(), value.clone());
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_to_snake_case() {
505        assert_eq!(to_snake_case("PascalCase"), "pascal_case");
506        assert_eq!(to_snake_case("camelCase"), "camel_case");
507        assert_eq!(to_snake_case("Pool"), "pool");
508        assert_eq!(to_snake_case("VirtualService"), "virtual_service");
509    }
510
511    #[test]
512    fn test_to_pascal_case() {
513        assert_eq!(to_pascal_case("snake_case"), "SnakeCase");
514        assert_eq!(to_pascal_case("pool"), "Pool");
515        assert_eq!(to_pascal_case("virtual_service"), "VirtualService");
516    }
517
518    #[test]
519    fn test_extracted_values() {
520        let mut values = ExtractedValues::new();
521        values.set("pool_id".to_string(), serde_json::json!("abc123"));
522        values.set("name".to_string(), serde_json::json!("test-pool"));
523
524        assert_eq!(values.get("pool_id"), Some(&serde_json::json!("abc123")));
525        assert_eq!(values.get("missing"), None);
526    }
527
528    #[test]
529    fn test_spec_dependency_config_default() {
530        let config = SpecDependencyConfig::default();
531        assert!(config.execution_order.is_empty());
532        assert!(!config.disable_auto_detect);
533    }
534}