1use crate::openapi::spec::OpenApiSpec;
8use crate::{Error, Result};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tokio::fs;
12use tracing::{debug, info, warn};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ConflictStrategy {
17 Error,
19 First,
21 Last,
23}
24
25impl From<&str> for ConflictStrategy {
26 fn from(s: &str) -> Self {
27 match s {
28 "first" => ConflictStrategy::First,
29 "last" => ConflictStrategy::Last,
30 _ => ConflictStrategy::Error,
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub enum Conflict {
38 RouteConflict {
40 method: String,
42 path: String,
44 files: Vec<PathBuf>,
46 },
47 ComponentConflict {
49 component_type: String,
51 key: String,
53 files: Vec<PathBuf>,
55 },
56}
57
58#[derive(Debug)]
60pub enum MergeConflictError {
61 RouteConflict {
63 method: String,
65 path: String,
67 files: Vec<PathBuf>,
69 },
70 ComponentConflict {
72 component_type: String,
74 key: String,
76 files: Vec<PathBuf>,
78 },
79}
80
81impl std::fmt::Display for MergeConflictError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 MergeConflictError::RouteConflict {
85 method,
86 path,
87 files,
88 } => {
89 write!(
90 f,
91 "Conflict: {} {} defined in {}",
92 method,
93 path,
94 files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
95 )
96 }
97 MergeConflictError::ComponentConflict {
98 component_type,
99 key,
100 files,
101 } => {
102 write!(
103 f,
104 "Conflict: components.{} defined differently in {}",
105 component_type,
106 files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
107 )
108 }
109 }
110 }
111}
112
113impl std::error::Error for MergeConflictError {}
114
115pub async fn load_specs_from_directory(dir: &Path) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
121 use globwalk::GlobWalkerBuilder;
122
123 info!("Discovering OpenAPI specs in directory: {}", dir.display());
124
125 if !dir.exists() {
126 return Err(Error::generic(format!("Directory does not exist: {}", dir.display())));
127 }
128
129 if !dir.is_dir() {
130 return Err(Error::generic(format!("Path is not a directory: {}", dir.display())));
131 }
132
133 let mut spec_files = Vec::new();
135 let walker = GlobWalkerBuilder::from_patterns(dir, &["**/*.json", "**/*.yaml", "**/*.yml"])
136 .build()
137 .map_err(|e| Error::generic(format!("Failed to walk directory: {}", e)))?;
138
139 for entry in walker {
140 let entry =
141 entry.map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?;
142 let path = entry.path();
143 if path.is_file() {
144 spec_files.push(path.to_path_buf());
145 }
146 }
147
148 spec_files.sort();
150
151 if spec_files.is_empty() {
152 warn!("No OpenAPI spec files found in directory: {}", dir.display());
153 return Ok(Vec::new());
154 }
155
156 info!("Found {} spec files, loading...", spec_files.len());
157
158 let mut specs = Vec::new();
160 for file_path in spec_files {
161 match OpenApiSpec::from_file(&file_path).await {
162 Ok(spec) => {
163 debug!("Loaded spec from: {}", file_path.display());
164 specs.push((file_path, spec));
165 }
166 Err(e) => {
167 warn!("Failed to load spec from {}: {}", file_path.display(), e);
168 }
170 }
171 }
172
173 info!("Successfully loaded {} specs from directory", specs.len());
174 Ok(specs)
175}
176
177pub async fn load_specs_from_files(files: Vec<PathBuf>) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
179 info!("Loading {} OpenAPI spec files", files.len());
180
181 let mut specs = Vec::new();
182 for file_path in files {
183 match OpenApiSpec::from_file(&file_path).await {
184 Ok(spec) => {
185 debug!("Loaded spec from: {}", file_path.display());
186 specs.push((file_path, spec));
187 }
188 Err(e) => {
189 return Err(Error::generic(format!(
190 "Failed to load spec from {}: {}",
191 file_path.display(),
192 e
193 )));
194 }
195 }
196 }
197
198 info!("Successfully loaded {} specs", specs.len());
199 Ok(specs)
200}
201
202pub fn group_specs_by_openapi_version(
206 specs: Vec<(PathBuf, OpenApiSpec)>,
207) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
208 let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
209
210 for (path, spec) in specs {
211 let version = spec
213 .raw_document
214 .as_ref()
215 .and_then(|doc| doc.get("openapi"))
216 .and_then(|v| v.as_str())
217 .map(|s| s.to_string())
218 .unwrap_or_else(|| "unknown".to_string());
219
220 groups.entry(version.clone()).or_insert_with(Vec::new).push((path, spec));
221 }
222
223 info!("Grouped specs into {} OpenAPI version groups", groups.len());
224 for (version, specs_in_group) in &groups {
225 info!(" OpenAPI {}: {} specs", version, specs_in_group.len());
226 }
227
228 groups
229}
230
231pub fn group_specs_by_api_version(
236 specs: Vec<(PathBuf, OpenApiSpec)>,
237) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
238 let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
239
240 for (path, spec) in specs {
241 let api_version = spec
243 .raw_document
244 .as_ref()
245 .and_then(|doc| doc.get("info"))
246 .and_then(|info| info.get("version"))
247 .and_then(|v| v.as_str())
248 .map(|s| s.to_string())
249 .unwrap_or_else(|| "unknown".to_string());
250
251 groups.entry(api_version.clone()).or_insert_with(Vec::new).push((path, spec));
252 }
253
254 info!("Grouped specs into {} API version groups", groups.len());
255 for (version, specs_in_group) in &groups {
256 info!(" API version {}: {} specs", version, specs_in_group.len());
257 }
258
259 groups
260}
261
262pub fn detect_conflicts(specs: &[(PathBuf, OpenApiSpec)]) -> Vec<Conflict> {
266 let mut conflicts = Vec::new();
267
268 let mut routes: HashMap<(String, String), Vec<PathBuf>> = HashMap::new();
270 for (path, spec) in specs {
271 for (route_path, path_item_ref) in &spec.spec.paths.paths {
272 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
273 let methods = vec![
275 ("GET", path_item.get.as_ref()),
276 ("POST", path_item.post.as_ref()),
277 ("PUT", path_item.put.as_ref()),
278 ("DELETE", path_item.delete.as_ref()),
279 ("PATCH", path_item.patch.as_ref()),
280 ("HEAD", path_item.head.as_ref()),
281 ("OPTIONS", path_item.options.as_ref()),
282 ];
283
284 for (method, operation) in methods {
285 if operation.is_some() {
286 let key = (method.to_string(), route_path.clone());
287 routes.entry(key).or_insert_with(Vec::new).push(path.clone());
288 }
289 }
290 }
291 }
292 }
293
294 for ((method, route_path), files) in routes {
296 if files.len() > 1 {
297 conflicts.push(Conflict::RouteConflict {
298 method,
299 path: route_path,
300 files,
301 });
302 }
303 }
304
305 for component_type in &[
307 "schemas",
308 "parameters",
309 "responses",
310 "requestBodies",
311 "headers",
312 "examples",
313 "links",
314 "callbacks",
315 ] {
316 let mut components: HashMap<String, Vec<PathBuf>> = HashMap::new();
317
318 for (path, spec) in specs {
319 if let Some(components_obj) = spec
320 .raw_document
321 .as_ref()
322 .and_then(|doc| doc.get("components"))
323 .and_then(|c| c.get(component_type))
324 {
325 if let Some(components_map) = components_obj.as_object() {
326 for key in components_map.keys() {
327 components.entry(key.clone()).or_insert_with(Vec::new).push(path.clone());
328 }
329 }
330 }
331 }
332
333 for (key, files) in components {
335 if files.len() > 1 {
336 let mut definitions = Vec::new();
338 for (file_path, spec) in specs {
339 if files.contains(file_path) {
340 if let Some(def) = spec
341 .raw_document
342 .as_ref()
343 .and_then(|doc| doc.get("components"))
344 .and_then(|c| c.get(component_type))
345 .and_then(|ct| ct.get(&key))
346 {
347 definitions.push((file_path.clone(), def.clone()));
348 }
349 }
350 }
351
352 let first_def = &definitions[0].1;
354 let all_identical = definitions.iter().all(|(_, def)| {
355 serde_json::to_string(def).ok() == serde_json::to_string(first_def).ok()
356 });
357
358 if !all_identical {
359 conflicts.push(Conflict::ComponentConflict {
360 component_type: component_type.to_string(),
361 key,
362 files,
363 });
364 }
365 }
366 }
367 }
368
369 conflicts
370}
371
372pub fn merge_specs(
377 specs: Vec<(PathBuf, OpenApiSpec)>,
378 conflict_strategy: ConflictStrategy,
379) -> std::result::Result<OpenApiSpec, MergeConflictError> {
380 if specs.is_empty() {
381 return Err(MergeConflictError::ComponentConflict {
382 component_type: "general".to_string(),
383 key: "no_specs".to_string(),
384 files: Vec::new(),
385 });
386 }
387
388 if specs.len() == 1 {
389 return Ok(specs.into_iter().next().unwrap().1);
391 }
392
393 let conflicts = detect_conflicts(&specs);
395
396 match conflict_strategy {
398 ConflictStrategy::Error => {
399 if !conflicts.is_empty() {
400 match &conflicts[0] {
402 Conflict::RouteConflict {
403 method,
404 path,
405 files,
406 } => {
407 return Err(MergeConflictError::RouteConflict {
408 method: method.clone(),
409 path: path.clone(),
410 files: files.clone(),
411 });
412 }
413 Conflict::ComponentConflict {
414 component_type,
415 key,
416 files,
417 } => {
418 return Err(MergeConflictError::ComponentConflict {
419 component_type: component_type.clone(),
420 key: key.clone(),
421 files: files.clone(),
422 });
423 }
424 }
425 }
426 }
427 ConflictStrategy::First | ConflictStrategy::Last => {
428 for conflict in &conflicts {
430 match conflict {
431 Conflict::RouteConflict {
432 method,
433 path,
434 files,
435 } => {
436 warn!(
437 "Route conflict: {} {} defined in multiple files: {:?}. Using {} definition.",
438 method, path, files,
439 if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
440 );
441 }
442 Conflict::ComponentConflict {
443 component_type,
444 key,
445 files,
446 } => {
447 warn!(
448 "Component conflict: components.{} defined in multiple files: {}. Using {} definition (strategy: {}).",
449 component_type, key, files.iter().map(|f| f.display().to_string()).collect::<Vec<_>>().join(", "),
450 if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
451 );
452 }
453 }
454 }
455 }
456 }
457
458 let all_file_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
460
461 let mut base_spec = specs[0].1.clone();
463 let mut base_doc = base_spec
464 .raw_document
465 .as_ref()
466 .cloned()
467 .unwrap_or_else(|| serde_json::json!({}));
468
469 let specs_to_merge: Vec<&(PathBuf, OpenApiSpec)> =
471 if conflict_strategy == ConflictStrategy::Last {
472 specs.iter().skip(1).collect()
473 } else {
474 specs.iter().skip(1).collect()
475 };
476
477 for (file_path, spec) in specs_to_merge {
479 let spec_doc = spec.raw_document.as_ref().cloned().unwrap_or_else(|| serde_json::json!({}));
480
481 if let Some(paths) = spec_doc.get("paths").and_then(|p| p.as_object()) {
483 if base_doc.get("paths").is_none() {
484 base_doc["paths"] = serde_json::json!({});
485 }
486 let base_paths = base_doc["paths"].as_object_mut().unwrap();
487 for (path, path_item) in paths {
488 if base_paths.contains_key(path) {
489 if conflict_strategy == ConflictStrategy::Last {
491 base_paths.insert(path.clone(), path_item.clone());
492 }
493 } else {
495 base_paths.insert(path.clone(), path_item.clone());
496 }
497 }
498 }
499
500 if let Some(components) = spec_doc.get("components").and_then(|c| c.as_object()) {
502 if base_doc.get("components").is_none() {
503 base_doc["components"] = serde_json::json!({});
504 }
505 let base_components = base_doc["components"].as_object_mut().unwrap();
506 for (component_type, component_obj) in components {
507 if let Some(component_map) = component_obj.as_object() {
508 let base_component_map = base_components
509 .entry(component_type.clone())
510 .or_insert_with(|| serde_json::json!({}))
511 .as_object_mut()
512 .unwrap();
513
514 for (key, value) in component_map {
515 if base_component_map.contains_key(key) {
516 let existing = base_component_map.get(key).unwrap();
518 if serde_json::to_string(existing).ok()
519 != serde_json::to_string(value).ok()
520 {
521 if conflict_strategy == ConflictStrategy::Last {
523 base_component_map.insert(key.clone(), value.clone());
524 }
525 }
527 } else {
529 base_component_map.insert(key.clone(), value.clone());
530 }
531 }
532 }
533 }
534 }
535 }
536
537 let merged_spec: openapiv3::OpenAPI =
539 serde_json::from_value(base_doc.clone()).map_err(|e| {
540 MergeConflictError::ComponentConflict {
541 component_type: "parsing".to_string(),
542 key: format!("merge_error: {}", e),
543 files: all_file_paths,
544 }
545 })?;
546
547 Ok(OpenApiSpec {
548 spec: merged_spec,
549 file_path: None, raw_document: Some(base_doc),
551 })
552}