1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct SpecDependencyConfig {
18 #[serde(default)]
20 pub execution_order: Vec<SpecGroup>,
21 #[serde(default)]
23 pub disable_auto_detect: bool,
24}
25
26impl SpecDependencyConfig {
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SpecGroup {
51 pub name: String,
53 pub specs: Vec<PathBuf>,
55 #[serde(default)]
57 pub extract: HashMap<String, String>,
58 #[serde(default)]
60 pub inject: HashMap<String, String>,
61}
62
63#[derive(Debug, Clone)]
65pub struct SpecDependency {
66 pub dependent_spec: PathBuf,
68 pub dependency_spec: PathBuf,
70 pub field_name: String,
72 pub referenced_schema: String,
74 pub extraction_path: String,
76}
77
78pub struct DependencyDetector {
80 schema_registry: HashMap<PathBuf, HashSet<String>>,
82 dependencies: Vec<SpecDependency>,
84}
85
86impl DependencyDetector {
87 pub fn new() -> Self {
89 Self {
90 schema_registry: HashMap::new(),
91 dependencies: Vec::new(),
92 }
93 }
94
95 pub fn detect_dependencies(&mut self, specs: &[(PathBuf, OpenApiSpec)]) -> Vec<SpecDependency> {
97 for (path, spec) in specs {
99 let schemas = self.extract_schema_names(spec);
100 self.schema_registry.insert(path.clone(), schemas);
101 }
102
103 for (path, spec) in specs {
105 self.analyze_spec_references(path, spec, specs);
106 }
107
108 self.dependencies.clone()
109 }
110
111 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 schemas.insert(name.to_lowercase());
120 schemas.insert(to_snake_case(name));
121 }
122 }
123
124 schemas
125 }
126
127 fn analyze_spec_references(
129 &mut self,
130 current_path: &PathBuf,
131 spec: &OpenApiSpec,
132 all_specs: &[(PathBuf, OpenApiSpec)],
133 ) {
134 for (path, path_item) in &spec.spec.paths.paths {
136 if let openapiv3::ReferenceOr::Item(item) = path_item {
137 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 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 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 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 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 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 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 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 fn analyze_reference(
258 &mut self,
259 current_path: &PathBuf,
260 reference: &str,
261 all_specs: &[(PathBuf, OpenApiSpec)],
262 field_prefix: &str,
263 ) {
264 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 if let Some(parent) = current_path.parent() {
272 let resolved = parent.join(file_part);
273 let schema_name =
275 json_pointer.rsplit('/').next().unwrap_or(json_pointer).to_string();
276
277 for (other_path, _) in all_specs {
279 if other_path == current_path {
280 continue;
281 }
282 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 let schema_name = json_pointer.rsplit('/').next().unwrap_or(json_pointer);
300
301 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 fn detect_ref_field(
325 &self,
326 current_path: &PathBuf,
327 field_name: &str,
328 all_specs: &[(PathBuf, OpenApiSpec)],
329 ) -> Option<(PathBuf, String)> {
330 let ref_patterns = [
332 ("_ref", ""), ("_id", ""), ("Id", ""), ("_uuid", ""), ("Uuid", ""), ("_reference", ""), ];
339
340 for (suffix, _) in ref_patterns.iter() {
341 if field_name.ends_with(suffix) {
342 let schema_base = field_name.trim_end_matches(suffix).trim_end_matches('_');
344
345 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 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
379pub 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 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 let mut queue: Vec<PathBuf> = in_degree
404 .iter()
405 .filter(|(_, °)| 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
433fn 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
449fn 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#[derive(Debug, Clone, Default)]
470pub struct ExtractedValues {
471 pub values: HashMap<String, serde_json::Value>,
473}
474
475impl ExtractedValues {
476 pub fn new() -> Self {
478 Self::default()
479 }
480
481 pub fn set(&mut self, key: String, value: serde_json::Value) {
483 self.values.insert(key, value);
484 }
485
486 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
488 self.values.get(key)
489 }
490
491 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}