1use crate::openapi::spec::OpenApiSpec;
8use crate::{Error, Result};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tracing::{debug, info, warn};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConflictStrategy {
16 Error,
18 First,
20 Last,
22}
23
24impl From<&str> for ConflictStrategy {
25 fn from(s: &str) -> Self {
26 match s {
27 "first" => ConflictStrategy::First,
28 "last" => ConflictStrategy::Last,
29 _ => ConflictStrategy::Error,
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub enum Conflict {
37 RouteConflict {
39 method: String,
41 path: String,
43 files: Vec<PathBuf>,
45 },
46 ComponentConflict {
48 component_type: String,
50 key: String,
52 files: Vec<PathBuf>,
54 },
55}
56
57#[derive(Debug)]
59pub enum MergeConflictError {
60 RouteConflict {
62 method: String,
64 path: String,
66 files: Vec<PathBuf>,
68 },
69 ComponentConflict {
71 component_type: String,
73 key: String,
75 files: Vec<PathBuf>,
77 },
78 MultipleConflicts {
80 conflicts: Vec<Conflict>,
82 },
83}
84
85impl std::fmt::Display for MergeConflictError {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 MergeConflictError::MultipleConflicts { conflicts } => {
89 writeln!(f, "Found {} spec conflict(s):\n", conflicts.len())?;
90 for (i, conflict) in conflicts.iter().enumerate() {
91 match conflict {
92 Conflict::RouteConflict {
93 method,
94 path,
95 files,
96 } => {
97 writeln!(f, " {}. {} {} defined in:", i + 1, method, path)?;
98 for file in files {
99 writeln!(f, " - {}", file.display())?;
100 }
101 }
102 Conflict::ComponentConflict {
103 component_type,
104 key,
105 files,
106 } => {
107 writeln!(
108 f,
109 " {}. components.{}.{} defined in:",
110 i + 1,
111 component_type,
112 key
113 )?;
114 for file in files {
115 writeln!(f, " - {}", file.display())?;
116 }
117 }
118 }
119 }
120 writeln!(f)?;
121 write!(
122 f,
123 "Resolution options:\n\
124 - Use --merge-conflicts=first to keep the first definition\n\
125 - Use --merge-conflicts=last to keep the last definition\n\
126 - Remove duplicate routes/components from conflicting spec files"
127 )
128 }
129 MergeConflictError::RouteConflict {
130 method,
131 path,
132 files,
133 } => {
134 write!(
135 f,
136 "Conflict: {} {} defined in {}",
137 method,
138 path,
139 files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
140 )
141 }
142 MergeConflictError::ComponentConflict {
143 component_type,
144 key,
145 files,
146 } => {
147 write!(
148 f,
149 "Conflict: components.{}.{} defined differently in {}",
150 component_type,
151 key,
152 files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
153 )
154 }
155 }
156 }
157}
158
159impl std::error::Error for MergeConflictError {}
160
161pub async fn load_specs_from_directory(dir: &Path) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
167 use globwalk::GlobWalkerBuilder;
168
169 info!("Discovering OpenAPI specs in directory: {}", dir.display());
170
171 if !dir.exists() {
172 return Err(Error::generic(format!("Directory does not exist: {}", dir.display())));
173 }
174
175 if !dir.is_dir() {
176 return Err(Error::generic(format!("Path is not a directory: {}", dir.display())));
177 }
178
179 let mut spec_files = Vec::new();
181 let walker = GlobWalkerBuilder::from_patterns(dir, &["**/*.json", "**/*.yaml", "**/*.yml"])
182 .build()
183 .map_err(|e| Error::generic(format!("Failed to walk directory: {}", e)))?;
184
185 for entry in walker {
186 let entry =
187 entry.map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?;
188 let path = entry.path();
189 if path.is_file() {
190 spec_files.push(path.to_path_buf());
191 }
192 }
193
194 spec_files.sort();
196
197 if spec_files.is_empty() {
198 warn!("No OpenAPI spec files found in directory: {}", dir.display());
199 return Ok(Vec::new());
200 }
201
202 info!("Found {} spec files, loading...", spec_files.len());
203
204 let mut specs = Vec::new();
206 for file_path in spec_files {
207 match OpenApiSpec::from_file(&file_path).await {
208 Ok(spec) => {
209 debug!("Loaded spec from: {}", file_path.display());
210 specs.push((file_path, spec));
211 }
212 Err(e) => {
213 warn!("Failed to load spec from {}: {}", file_path.display(), e);
214 }
216 }
217 }
218
219 info!("Successfully loaded {} specs from directory", specs.len());
220 Ok(specs)
221}
222
223pub async fn load_specs_from_files(files: Vec<PathBuf>) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
225 info!("Loading {} OpenAPI spec files", files.len());
226
227 let mut specs = Vec::new();
228 for file_path in files {
229 match OpenApiSpec::from_file(&file_path).await {
230 Ok(spec) => {
231 debug!("Loaded spec from: {}", file_path.display());
232 specs.push((file_path, spec));
233 }
234 Err(e) => {
235 return Err(Error::generic(format!(
236 "Failed to load spec from {}: {}",
237 file_path.display(),
238 e
239 )));
240 }
241 }
242 }
243
244 info!("Successfully loaded {} specs", specs.len());
245 Ok(specs)
246}
247
248pub fn group_specs_by_openapi_version(
252 specs: Vec<(PathBuf, OpenApiSpec)>,
253) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
254 let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
255
256 for (path, spec) in specs {
257 let version = spec
259 .raw_document
260 .as_ref()
261 .and_then(|doc| doc.get("openapi"))
262 .and_then(|v| v.as_str())
263 .map(|s| s.to_string())
264 .unwrap_or_else(|| "unknown".to_string());
265
266 groups.entry(version.clone()).or_default().push((path, spec));
267 }
268
269 info!("Grouped specs into {} OpenAPI version groups", groups.len());
270 for (version, specs_in_group) in &groups {
271 info!(" OpenAPI {}: {} specs", version, specs_in_group.len());
272 }
273
274 groups
275}
276
277pub fn group_specs_by_api_version(
282 specs: Vec<(PathBuf, OpenApiSpec)>,
283) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
284 let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
285
286 for (path, spec) in specs {
287 let api_version = spec
289 .raw_document
290 .as_ref()
291 .and_then(|doc| doc.get("info"))
292 .and_then(|info| info.get("version"))
293 .and_then(|v| v.as_str())
294 .map(|s| s.to_string())
295 .unwrap_or_else(|| "unknown".to_string());
296
297 groups.entry(api_version.clone()).or_default().push((path, spec));
298 }
299
300 info!("Grouped specs into {} API version groups", groups.len());
301 for (version, specs_in_group) in &groups {
302 info!(" API version {}: {} specs", version, specs_in_group.len());
303 }
304
305 groups
306}
307
308pub fn detect_conflicts(specs: &[(PathBuf, OpenApiSpec)]) -> Vec<Conflict> {
312 let mut conflicts = Vec::new();
313
314 let mut routes: HashMap<(String, String), Vec<PathBuf>> = HashMap::new();
316 for (path, spec) in specs {
317 for (route_path, path_item_ref) in &spec.spec.paths.paths {
318 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
319 let methods = vec![
321 ("GET", path_item.get.as_ref()),
322 ("POST", path_item.post.as_ref()),
323 ("PUT", path_item.put.as_ref()),
324 ("DELETE", path_item.delete.as_ref()),
325 ("PATCH", path_item.patch.as_ref()),
326 ("HEAD", path_item.head.as_ref()),
327 ("OPTIONS", path_item.options.as_ref()),
328 ];
329
330 for (method, operation) in methods {
331 if operation.is_some() {
332 let key = (method.to_string(), route_path.clone());
333 routes.entry(key).or_default().push(path.clone());
334 }
335 }
336 }
337 }
338 }
339
340 for ((method, route_path), files) in routes {
342 if files.len() > 1 {
343 conflicts.push(Conflict::RouteConflict {
344 method,
345 path: route_path,
346 files,
347 });
348 }
349 }
350
351 for component_type in &[
353 "schemas",
354 "parameters",
355 "responses",
356 "requestBodies",
357 "headers",
358 "examples",
359 "links",
360 "callbacks",
361 ] {
362 let mut components: HashMap<String, Vec<PathBuf>> = HashMap::new();
363
364 for (path, spec) in specs {
365 if let Some(components_obj) = spec
366 .raw_document
367 .as_ref()
368 .and_then(|doc| doc.get("components"))
369 .and_then(|c| c.get(component_type))
370 {
371 if let Some(components_map) = components_obj.as_object() {
372 for key in components_map.keys() {
373 components.entry(key.clone()).or_default().push(path.clone());
374 }
375 }
376 }
377 }
378
379 for (key, files) in components {
381 if files.len() > 1 {
382 let mut definitions = Vec::new();
384 for (file_path, spec) in specs {
385 if files.contains(file_path) {
386 if let Some(def) = spec
387 .raw_document
388 .as_ref()
389 .and_then(|doc| doc.get("components"))
390 .and_then(|c| c.get(component_type))
391 .and_then(|ct| ct.get(&key))
392 {
393 definitions.push((file_path.clone(), def.clone()));
394 }
395 }
396 }
397
398 let first_def = &definitions[0].1;
400 let all_identical = definitions.iter().all(|(_, def)| {
401 serde_json::to_string(def).ok() == serde_json::to_string(first_def).ok()
402 });
403
404 if !all_identical {
405 conflicts.push(Conflict::ComponentConflict {
406 component_type: component_type.to_string(),
407 key,
408 files,
409 });
410 }
411 }
412 }
413 }
414
415 conflicts
416}
417
418pub fn merge_specs(
423 specs: Vec<(PathBuf, OpenApiSpec)>,
424 conflict_strategy: ConflictStrategy,
425) -> std::result::Result<OpenApiSpec, MergeConflictError> {
426 if specs.is_empty() {
427 return Err(MergeConflictError::ComponentConflict {
428 component_type: "general".to_string(),
429 key: "no_specs".to_string(),
430 files: Vec::new(),
431 });
432 }
433
434 if specs.len() == 1 {
435 return specs.into_iter().next().map(|(_, spec)| spec).ok_or_else(|| {
437 MergeConflictError::ComponentConflict {
438 component_type: "general".to_string(),
439 key: "no_specs".to_string(),
440 files: Vec::new(),
441 }
442 });
443 }
444
445 let conflicts = detect_conflicts(&specs);
447
448 match conflict_strategy {
450 ConflictStrategy::Error => {
451 if !conflicts.is_empty() {
452 return Err(MergeConflictError::MultipleConflicts {
454 conflicts: conflicts.clone(),
455 });
456 }
457 }
458 ConflictStrategy::First | ConflictStrategy::Last => {
459 for conflict in &conflicts {
461 match conflict {
462 Conflict::RouteConflict {
463 method,
464 path,
465 files,
466 } => {
467 warn!(
468 "Route conflict: {} {} defined in multiple files: {:?}. Using {} definition.",
469 method, path, files,
470 if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
471 );
472 }
473 Conflict::ComponentConflict {
474 component_type,
475 key,
476 files,
477 } => {
478 warn!(
479 "Component conflict: components.{} defined in multiple files: {}. Using {} definition (strategy: {}).",
480 component_type, key, files.iter().map(|f| f.display().to_string()).collect::<Vec<_>>().join(", "),
481 if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
482 );
483 }
484 }
485 }
486 }
487 }
488
489 let all_file_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
491
492 let base_spec = specs.first().map(|(_, spec)| spec.clone()).ok_or_else(|| {
494 MergeConflictError::ComponentConflict {
495 component_type: "general".to_string(),
496 key: "no_specs".to_string(),
497 files: Vec::new(),
498 }
499 })?;
500 #[allow(unused_mut)]
501 let mut base_doc = base_spec
502 .raw_document
503 .as_ref()
504 .cloned()
505 .unwrap_or_else(|| serde_json::json!({}));
506
507 let specs_to_merge: Vec<&(PathBuf, OpenApiSpec)> = specs.iter().skip(1).collect();
509
510 for (_file_path, spec) in specs_to_merge {
512 let spec_doc = spec.raw_document.as_ref().cloned().unwrap_or_else(|| serde_json::json!({}));
513
514 if let Some(paths) = spec_doc.get("paths").and_then(|p| p.as_object()) {
516 if base_doc.get("paths").is_none() {
517 base_doc["paths"] = serde_json::json!({});
518 }
519 let base_paths = base_doc["paths"].as_object_mut().ok_or_else(|| {
520 MergeConflictError::ComponentConflict {
521 component_type: "paths".to_string(),
522 key: "invalid_type".to_string(),
523 files: all_file_paths.clone(),
524 }
525 })?;
526 for (path, path_item) in paths {
527 if base_paths.contains_key(path) {
528 if conflict_strategy == ConflictStrategy::Last {
530 base_paths.insert(path.clone(), path_item.clone());
531 }
532 } else {
534 base_paths.insert(path.clone(), path_item.clone());
535 }
536 }
537 }
538
539 if let Some(components) = spec_doc.get("components").and_then(|c| c.as_object()) {
541 if base_doc.get("components").is_none() {
542 base_doc["components"] = serde_json::json!({});
543 }
544 let base_components = base_doc["components"].as_object_mut().ok_or_else(|| {
545 MergeConflictError::ComponentConflict {
546 component_type: "components".to_string(),
547 key: "invalid_type".to_string(),
548 files: all_file_paths.clone(),
549 }
550 })?;
551 for (component_type, component_obj) in components {
552 if let Some(component_map) = component_obj.as_object() {
553 let base_component_map = base_components
554 .entry(component_type.clone())
555 .or_insert_with(|| serde_json::json!({}))
556 .as_object_mut()
557 .ok_or_else(|| MergeConflictError::ComponentConflict {
558 component_type: component_type.clone(),
559 key: "invalid_type".to_string(),
560 files: all_file_paths.clone(),
561 })?;
562
563 for (key, value) in component_map {
564 if let Some(existing) = base_component_map.get(key) {
565 if serde_json::to_string(existing).ok()
567 != serde_json::to_string(value).ok()
568 {
569 if conflict_strategy == ConflictStrategy::Last {
571 base_component_map.insert(key.clone(), value.clone());
572 }
573 }
575 } else {
577 base_component_map.insert(key.clone(), value.clone());
578 }
579 }
580 }
581 }
582 }
583 }
584
585 let merged_spec: openapiv3::OpenAPI =
587 serde_json::from_value(base_doc.clone()).map_err(|e| {
588 MergeConflictError::ComponentConflict {
589 component_type: "parsing".to_string(),
590 key: format!("merge_error: {}", e),
591 files: all_file_paths,
592 }
593 })?;
594
595 Ok(OpenApiSpec {
596 spec: merged_spec,
597 file_path: None, raw_document: Some(base_doc),
599 })
600}