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