Skip to main content

clawspec_core/split/
strategies.rs

1//! Built-in splitting strategies for OpenAPI specifications.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::PathBuf;
5
6use utoipa::openapi::path::{Operation, PathItem};
7use utoipa::openapi::{Components, OpenApi, Ref, RefOr};
8
9use super::{Fragment, OpenApiSplitter, SplitResult};
10
11/// Helper to iterate over all operations in a PathItem.
12fn iter_operations(path_item: &PathItem) -> impl Iterator<Item = &Operation> {
13    [
14        path_item.get.as_ref(),
15        path_item.put.as_ref(),
16        path_item.post.as_ref(),
17        path_item.delete.as_ref(),
18        path_item.options.as_ref(),
19        path_item.head.as_ref(),
20        path_item.patch.as_ref(),
21        path_item.trace.as_ref(),
22    ]
23    .into_iter()
24    .flatten()
25}
26
27/// Splits schemas based on which tags use them.
28///
29/// This splitter analyzes which schemas are referenced by operations with specific tags
30/// and organizes them into separate files:
31///
32/// - Schemas used by only one tag go into a file named after that tag
33/// - Schemas used by multiple tags go into a common file
34///
35/// # Example
36///
37/// ```rust,ignore
38/// use clawspec_core::split::{OpenApiSplitter, SplitSchemasByTag};
39///
40/// let splitter = SplitSchemasByTag::new("common-types.yaml");
41/// let result = splitter.split(spec);
42///
43/// // Result might contain:
44/// // - main openapi.yaml with $refs to external files
45/// // - users.yaml with User, CreateUser schemas
46/// // - orders.yaml with Order, OrderItem schemas
47/// // - common-types.yaml with Error, Pagination schemas used by both
48/// ```
49#[derive(Debug, Clone)]
50pub struct SplitSchemasByTag {
51    /// Path for schemas used by multiple tags.
52    common_file: PathBuf,
53    /// Optional directory prefix for tag-specific files.
54    schemas_dir: Option<PathBuf>,
55}
56
57impl SplitSchemasByTag {
58    /// Creates a new splitter with the specified common file path.
59    ///
60    /// Tag-specific files will be created in the same directory as the common file.
61    pub fn new(common_file: impl Into<PathBuf>) -> Self {
62        Self {
63            common_file: common_file.into(),
64            schemas_dir: None,
65        }
66    }
67
68    /// Sets the directory for schema files.
69    ///
70    /// Both tag-specific and common files will be placed in this directory.
71    pub fn with_schemas_dir(mut self, dir: impl Into<PathBuf>) -> Self {
72        self.schemas_dir = Some(dir.into());
73        self
74    }
75
76    /// Analyzes which tags reference which schemas.
77    fn analyze_schema_usage(&self, spec: &OpenApi) -> BTreeMap<String, BTreeSet<String>> {
78        let mut schema_to_tags: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
79
80        // Iterate through all paths and operations
81        for (_path, path_item) in spec.paths.paths.iter() {
82            for operation in iter_operations(path_item) {
83                let tags = operation.tags.clone().unwrap_or_default();
84                if tags.is_empty() {
85                    continue;
86                }
87
88                // Collect schema references from request body
89                if let Some(ref request_body) = operation.request_body {
90                    for content in request_body.content.values() {
91                        if let Some(ref schema) = content.schema {
92                            self.collect_schema_refs(schema, &tags, &mut schema_to_tags);
93                        }
94                    }
95                }
96
97                // Collect schema references from responses
98                for response in operation.responses.responses.values() {
99                    if let RefOr::T(resp) = response {
100                        for content in resp.content.values() {
101                            if let Some(ref schema) = content.schema {
102                                self.collect_schema_refs(schema, &tags, &mut schema_to_tags);
103                            }
104                        }
105                    }
106                }
107
108                // Collect schema references from parameters
109                if let Some(ref parameters) = operation.parameters {
110                    for param in parameters {
111                        if let Some(ref schema) = param.schema {
112                            self.collect_schema_refs(schema, &tags, &mut schema_to_tags);
113                        }
114                    }
115                }
116            }
117        }
118
119        schema_to_tags
120    }
121
122    /// Collects schema references from a schema, adding tag associations.
123    fn collect_schema_refs(
124        &self,
125        schema: &RefOr<utoipa::openapi::Schema>,
126        tags: &[String],
127        schema_to_tags: &mut BTreeMap<String, BTreeSet<String>>,
128    ) {
129        match schema {
130            RefOr::Ref(r) => {
131                if let Some(name) = extract_schema_name(&r.ref_location) {
132                    let entry = schema_to_tags.entry(name).or_default();
133                    for tag in tags {
134                        entry.insert(tag.clone());
135                    }
136                }
137            }
138            RefOr::T(_) => {
139                // Inline schema, no reference to extract
140            }
141        }
142    }
143
144    /// Determines the target file for a schema based on its tag usage.
145    fn target_file_for_schema(&self, _schema_name: &str, tags: &BTreeSet<String>) -> PathBuf {
146        let base_dir = self.schemas_dir.clone().unwrap_or_default();
147
148        if tags.len() == 1 {
149            // Schema used by only one tag - put in tag-specific file
150            let tag = tags.iter().next().expect("checked len == 1");
151            base_dir.join(format!("{tag}.yaml"))
152        } else {
153            // Schema used by multiple tags or no tags - put in common file
154            if self.schemas_dir.is_some() {
155                base_dir.join(&self.common_file)
156            } else {
157                self.common_file.clone()
158            }
159        }
160    }
161
162    /// Creates external reference string for a schema in a file.
163    fn create_external_ref(file_path: &std::path::Path, schema_name: &str) -> String {
164        format!(
165            "{}#/components/schemas/{}",
166            file_path.display(),
167            schema_name
168        )
169    }
170}
171
172impl OpenApiSplitter for SplitSchemasByTag {
173    type Fragment = Components;
174
175    fn split(&self, mut spec: OpenApi) -> SplitResult<Self::Fragment> {
176        let schema_to_tags = self.analyze_schema_usage(&spec);
177
178        // Group schemas by their target file
179        let mut file_to_schemas: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
180        for (schema_name, tags) in &schema_to_tags {
181            let target = self.target_file_for_schema(schema_name, tags);
182            file_to_schemas
183                .entry(target)
184                .or_default()
185                .insert(schema_name.clone());
186        }
187
188        // If all schemas go to one file or no schemas, no splitting needed
189        if file_to_schemas.len() <= 1 {
190            return SplitResult::new(spec);
191        }
192
193        let mut result = SplitResult::new(spec.clone());
194
195        // Extract schemas and create fragments
196        let original_components = spec.components.take().unwrap_or_default();
197        let mut remaining_schemas = original_components.schemas.clone();
198
199        for (file_path, schema_names) in &file_to_schemas {
200            let mut fragment_components = Components::new();
201
202            for schema_name in schema_names {
203                if let Some(schema) = remaining_schemas.remove(schema_name) {
204                    fragment_components
205                        .schemas
206                        .insert(schema_name.clone(), schema);
207                }
208            }
209
210            if !fragment_components.schemas.is_empty() {
211                result.add_fragment(Fragment::new(file_path.clone(), fragment_components));
212            }
213        }
214
215        // Update the main spec's schema references to point to external files
216        let mut new_components = Components::new();
217
218        // Add external references for extracted schemas
219        for (file_path, schema_names) in &file_to_schemas {
220            for schema_name in schema_names {
221                let external_ref = Self::create_external_ref(file_path, schema_name);
222                new_components
223                    .schemas
224                    .insert(schema_name.clone(), RefOr::Ref(Ref::new(external_ref)));
225            }
226        }
227
228        // Keep any remaining schemas that weren't extracted
229        for (name, schema) in remaining_schemas {
230            new_components.schemas.insert(name, schema);
231        }
232
233        // Preserve security schemes and responses
234        new_components.security_schemes = original_components.security_schemes;
235        new_components.responses = original_components.responses;
236
237        result.main.components = Some(new_components);
238        result
239    }
240}
241
242/// Extracts schemas matching a predicate into a separate file.
243///
244/// This splitter allows fine-grained control over which schemas are extracted
245/// by providing a predicate function that determines whether a schema should
246/// be moved to the external file.
247///
248/// # Example
249///
250/// ```rust,ignore
251/// use clawspec_core::split::{OpenApiSplitter, ExtractSchemasByPredicate};
252///
253/// // Extract all error-related schemas
254/// let splitter = ExtractSchemasByPredicate::new(
255///     "errors.yaml",
256///     |name| name.contains("Error") || name.contains("Exception"),
257/// );
258/// let result = splitter.split(spec);
259/// ```
260#[derive(Clone)]
261pub struct ExtractSchemasByPredicate<F>
262where
263    F: Fn(&str) -> bool,
264{
265    /// Path for the extracted schemas file.
266    target_file: PathBuf,
267    /// Predicate function that returns true for schemas to extract.
268    predicate: F,
269}
270
271impl<F> ExtractSchemasByPredicate<F>
272where
273    F: Fn(&str) -> bool,
274{
275    /// Creates a new splitter with the specified target file and predicate.
276    ///
277    /// The predicate receives the schema name and should return `true`
278    /// if the schema should be extracted to the target file.
279    pub fn new(target_file: impl Into<PathBuf>, predicate: F) -> Self {
280        Self {
281            target_file: target_file.into(),
282            predicate,
283        }
284    }
285}
286
287impl<F> OpenApiSplitter for ExtractSchemasByPredicate<F>
288where
289    F: Fn(&str) -> bool,
290{
291    type Fragment = Components;
292
293    fn split(&self, mut spec: OpenApi) -> SplitResult<Self::Fragment> {
294        let Some(mut components) = spec.components.take() else {
295            return SplitResult::new(spec);
296        };
297
298        // Find schemas to extract (collect names first to avoid borrowing issues)
299        let schemas_to_extract: Vec<String> = components
300            .schemas
301            .keys()
302            .filter(|name| (self.predicate)(name))
303            .cloned()
304            .collect();
305
306        // If nothing to extract, return unchanged
307        if schemas_to_extract.is_empty() {
308            spec.components = Some(components);
309            return SplitResult::new(spec);
310        }
311
312        // Extract matching schemas
313        let mut extracted = Components::new();
314        for name in &schemas_to_extract {
315            if let Some(schema) = components.schemas.remove(name) {
316                extracted.schemas.insert(name.clone(), schema);
317            }
318        }
319
320        // Create external references for extracted schemas
321        for name in &schemas_to_extract {
322            let external_ref = format!(
323                "{}#/components/schemas/{}",
324                self.target_file.display(),
325                name
326            );
327            components
328                .schemas
329                .insert(name.clone(), RefOr::Ref(Ref::new(external_ref)));
330        }
331
332        spec.components = Some(components);
333
334        let mut result = SplitResult::new(spec);
335        result.add_fragment(Fragment::new(self.target_file.clone(), extracted));
336        result
337    }
338}
339
340/// Extracts the schema name from a $ref string.
341///
342/// # Example
343///
344/// ```rust,ignore
345/// assert_eq!(extract_schema_name("#/components/schemas/User"), Some("User".to_string()));
346/// ```
347fn extract_schema_name(ref_location: &str) -> Option<String> {
348    const SCHEMA_PREFIX: &str = "#/components/schemas/";
349    ref_location
350        .strip_prefix(SCHEMA_PREFIX)
351        .map(|s| s.to_string())
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use utoipa::openapi::path::OperationBuilder;
358    use utoipa::openapi::path::PathItemBuilder;
359    use utoipa::openapi::{ContentBuilder, ObjectBuilder, OpenApiBuilder, ResponseBuilder};
360
361    fn create_test_spec() -> OpenApi {
362        let user_schema = ObjectBuilder::new()
363            .property(
364                "id",
365                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
366            )
367            .property(
368                "name",
369                ObjectBuilder::new().schema_type(utoipa::openapi::Type::String),
370            )
371            .build();
372
373        let error_schema = ObjectBuilder::new()
374            .property(
375                "code",
376                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
377            )
378            .property(
379                "message",
380                ObjectBuilder::new().schema_type(utoipa::openapi::Type::String),
381            )
382            .build();
383
384        let order_schema = ObjectBuilder::new()
385            .property(
386                "id",
387                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
388            )
389            .property(
390                "total",
391                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Number),
392            )
393            .build();
394
395        let mut components = Components::new();
396        components
397            .schemas
398            .insert("User".to_string(), RefOr::T(user_schema.into()));
399        components
400            .schemas
401            .insert("Error".to_string(), RefOr::T(error_schema.into()));
402        components
403            .schemas
404            .insert("Order".to_string(), RefOr::T(order_schema.into()));
405
406        // Create operations with tags
407        let get_users = OperationBuilder::new()
408            .tags(Some(vec!["users".to_string()]))
409            .response(
410                "200",
411                ResponseBuilder::new()
412                    .content(
413                        "application/json",
414                        ContentBuilder::new()
415                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/User"))))
416                            .build(),
417                    )
418                    .build(),
419            )
420            .build();
421
422        let get_orders = OperationBuilder::new()
423            .tags(Some(vec!["orders".to_string()]))
424            .response(
425                "200",
426                ResponseBuilder::new()
427                    .content(
428                        "application/json",
429                        ContentBuilder::new()
430                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Order"))))
431                            .build(),
432                    )
433                    .build(),
434            )
435            .response(
436                "400",
437                ResponseBuilder::new()
438                    .content(
439                        "application/json",
440                        ContentBuilder::new()
441                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Error"))))
442                            .build(),
443                    )
444                    .build(),
445            )
446            .build();
447
448        let get_user_orders = OperationBuilder::new()
449            .tags(Some(vec!["users".to_string(), "orders".to_string()]))
450            .response(
451                "400",
452                ResponseBuilder::new()
453                    .content(
454                        "application/json",
455                        ContentBuilder::new()
456                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Error"))))
457                            .build(),
458                    )
459                    .build(),
460            )
461            .build();
462
463        let mut paths = utoipa::openapi::Paths::new();
464        paths.paths.insert(
465            "/users".to_string(),
466            PathItemBuilder::new()
467                .operation(utoipa::openapi::HttpMethod::Get, get_users)
468                .build(),
469        );
470        paths.paths.insert(
471            "/orders".to_string(),
472            PathItemBuilder::new()
473                .operation(utoipa::openapi::HttpMethod::Get, get_orders)
474                .build(),
475        );
476        paths.paths.insert(
477            "/users/{id}/orders".to_string(),
478            PathItemBuilder::new()
479                .operation(utoipa::openapi::HttpMethod::Get, get_user_orders)
480                .build(),
481        );
482
483        OpenApiBuilder::new()
484            .paths(paths)
485            .components(Some(components))
486            .build()
487    }
488
489    #[test]
490    fn should_extract_schema_name() {
491        assert_eq!(
492            extract_schema_name("#/components/schemas/User"),
493            Some("User".to_string())
494        );
495        assert_eq!(
496            extract_schema_name("#/components/schemas/MyError"),
497            Some("MyError".to_string())
498        );
499        assert_eq!(extract_schema_name("#/components/responses/Error"), None);
500        assert_eq!(extract_schema_name("User"), None);
501    }
502
503    #[test]
504    fn should_split_by_predicate() {
505        let spec = create_test_spec();
506
507        let splitter = ExtractSchemasByPredicate::new("errors.yaml", |name| name.contains("Error"));
508        let result = splitter.split(spec);
509
510        assert_eq!(result.fragment_count(), 1);
511        let fragment = &result.fragments[0];
512        assert_eq!(fragment.path, PathBuf::from("errors.yaml"));
513        assert!(fragment.content.schemas.contains_key("Error"));
514        assert!(!fragment.content.schemas.contains_key("User"));
515        assert!(!fragment.content.schemas.contains_key("Order"));
516
517        // Main spec should have external reference for Error
518        let main_components = result
519            .main
520            .components
521            .as_ref()
522            .expect("should have components");
523        match main_components.schemas.get("Error") {
524            Some(RefOr::Ref(r)) => {
525                assert!(r.ref_location.contains("errors.yaml"));
526            }
527            _ => panic!("Expected external reference for Error"),
528        }
529    }
530
531    #[test]
532    fn should_not_split_when_predicate_matches_nothing() {
533        let spec = create_test_spec();
534
535        let splitter =
536            ExtractSchemasByPredicate::new("nothing.yaml", |name| name.contains("NonExistent"));
537        let result = splitter.split(spec);
538
539        assert!(result.is_unsplit());
540    }
541
542    #[test]
543    fn should_analyze_schema_usage() {
544        let spec = create_test_spec();
545        let splitter = SplitSchemasByTag::new("common.yaml");
546
547        let usage = splitter.analyze_schema_usage(&spec);
548
549        // User is used by "users" tag
550        assert!(
551            usage
552                .get("User")
553                .map(|t| t.contains("users"))
554                .unwrap_or(false)
555        );
556
557        // Order is used by "orders" tag
558        assert!(
559            usage
560                .get("Order")
561                .map(|t| t.contains("orders"))
562                .unwrap_or(false)
563        );
564
565        // Error is used by both "users" and "orders" tags
566        let error_tags = usage.get("Error").expect("Error should be tracked");
567        assert!(error_tags.contains("orders"));
568    }
569
570    #[test]
571    fn should_not_split_when_all_schemas_map_to_one_file() {
572        // Create a spec where all schemas are used by the same tag
573        let user_schema = ObjectBuilder::new()
574            .property(
575                "id",
576                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
577            )
578            .build();
579
580        let profile_schema = ObjectBuilder::new()
581            .property(
582                "bio",
583                ObjectBuilder::new().schema_type(utoipa::openapi::Type::String),
584            )
585            .build();
586
587        let mut components = Components::new();
588        components
589            .schemas
590            .insert("User".to_string(), RefOr::T(user_schema.into()));
591        components
592            .schemas
593            .insert("Profile".to_string(), RefOr::T(profile_schema.into()));
594
595        // All operations have the same tag
596        let get_users = OperationBuilder::new()
597            .tags(Some(vec!["users".to_string()]))
598            .response(
599                "200",
600                ResponseBuilder::new()
601                    .content(
602                        "application/json",
603                        ContentBuilder::new()
604                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/User"))))
605                            .build(),
606                    )
607                    .build(),
608            )
609            .build();
610
611        let get_profile = OperationBuilder::new()
612            .tags(Some(vec!["users".to_string()]))
613            .response(
614                "200",
615                ResponseBuilder::new()
616                    .content(
617                        "application/json",
618                        ContentBuilder::new()
619                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Profile"))))
620                            .build(),
621                    )
622                    .build(),
623            )
624            .build();
625
626        let mut paths = utoipa::openapi::Paths::new();
627        paths.paths.insert(
628            "/users".to_string(),
629            PathItemBuilder::new()
630                .operation(utoipa::openapi::HttpMethod::Get, get_users)
631                .build(),
632        );
633        paths.paths.insert(
634            "/profile".to_string(),
635            PathItemBuilder::new()
636                .operation(utoipa::openapi::HttpMethod::Get, get_profile)
637                .build(),
638        );
639
640        let spec = OpenApiBuilder::new()
641            .paths(paths)
642            .components(Some(components))
643            .build();
644
645        let splitter = SplitSchemasByTag::new("common.yaml");
646        let result = splitter.split(spec);
647
648        // Should not split because all schemas go to users.yaml
649        assert!(result.is_unsplit());
650    }
651
652    #[test]
653    fn should_collect_schemas_from_parameters() {
654        use utoipa::openapi::path::ParameterBuilder;
655        use utoipa::openapi::path::ParameterIn;
656
657        let id_schema = ObjectBuilder::new()
658            .schema_type(utoipa::openapi::Type::String)
659            .build();
660
661        let mut components = Components::new();
662        components
663            .schemas
664            .insert("UserId".to_string(), RefOr::T(id_schema.into()));
665
666        // Operation with a parameter that references a schema
667        let get_user = OperationBuilder::new()
668            .tags(Some(vec!["users".to_string()]))
669            .parameter(
670                ParameterBuilder::new()
671                    .name("id")
672                    .parameter_in(ParameterIn::Path)
673                    .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/UserId"))))
674                    .build(),
675            )
676            .response("200", ResponseBuilder::new().description("OK").build())
677            .build();
678
679        let mut paths = utoipa::openapi::Paths::new();
680        paths.paths.insert(
681            "/users/{id}".to_string(),
682            PathItemBuilder::new()
683                .operation(utoipa::openapi::HttpMethod::Get, get_user)
684                .build(),
685        );
686
687        let spec = OpenApiBuilder::new()
688            .paths(paths)
689            .components(Some(components))
690            .build();
691
692        let splitter = SplitSchemasByTag::new("common.yaml");
693        let usage = splitter.analyze_schema_usage(&spec);
694
695        // UserId should be tracked as used by "users" tag
696        assert!(
697            usage
698                .get("UserId")
699                .map(|t| t.contains("users"))
700                .unwrap_or(false)
701        );
702    }
703
704    #[test]
705    fn should_analyze_non_get_operations() {
706        let user_schema = ObjectBuilder::new()
707            .property(
708                "id",
709                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
710            )
711            .build();
712
713        let order_schema = ObjectBuilder::new()
714            .property(
715                "id",
716                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
717            )
718            .build();
719
720        let mut components = Components::new();
721        components
722            .schemas
723            .insert("User".to_string(), RefOr::T(user_schema.into()));
724        components
725            .schemas
726            .insert("Order".to_string(), RefOr::T(order_schema.into()));
727
728        // PUT operation
729        let update_user = OperationBuilder::new()
730            .tags(Some(vec!["users".to_string()]))
731            .response(
732                "200",
733                ResponseBuilder::new()
734                    .content(
735                        "application/json",
736                        ContentBuilder::new()
737                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/User"))))
738                            .build(),
739                    )
740                    .build(),
741            )
742            .build();
743
744        // DELETE operation
745        let delete_order = OperationBuilder::new()
746            .tags(Some(vec!["orders".to_string()]))
747            .response(
748                "200",
749                ResponseBuilder::new()
750                    .content(
751                        "application/json",
752                        ContentBuilder::new()
753                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Order"))))
754                            .build(),
755                    )
756                    .build(),
757            )
758            .build();
759
760        let mut paths = utoipa::openapi::Paths::new();
761        paths.paths.insert(
762            "/users/{id}".to_string(),
763            PathItemBuilder::new()
764                .operation(utoipa::openapi::HttpMethod::Put, update_user)
765                .build(),
766        );
767        paths.paths.insert(
768            "/orders/{id}".to_string(),
769            PathItemBuilder::new()
770                .operation(utoipa::openapi::HttpMethod::Delete, delete_order)
771                .build(),
772        );
773
774        let spec = OpenApiBuilder::new()
775            .paths(paths)
776            .components(Some(components))
777            .build();
778
779        let splitter = SplitSchemasByTag::new("common.yaml");
780        let usage = splitter.analyze_schema_usage(&spec);
781
782        // Both schemas should be tracked from PUT and DELETE operations
783        assert!(
784            usage
785                .get("User")
786                .map(|t| t.contains("users"))
787                .unwrap_or(false)
788        );
789        assert!(
790            usage
791                .get("Order")
792                .map(|t| t.contains("orders"))
793                .unwrap_or(false)
794        );
795    }
796
797    #[test]
798    fn should_preserve_security_schemes_after_split() {
799        use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
800
801        let spec = create_test_spec();
802        let mut spec_with_security = spec;
803
804        // Add security schemes
805        let mut security_schemes = BTreeMap::new();
806        security_schemes.insert(
807            "bearer_auth".to_string(),
808            SecurityScheme::Http(
809                HttpBuilder::new()
810                    .scheme(HttpAuthScheme::Bearer)
811                    .bearer_format("JWT")
812                    .build(),
813            ),
814        );
815
816        if let Some(ref mut components) = spec_with_security.components {
817            components.security_schemes = security_schemes;
818        }
819
820        let splitter = SplitSchemasByTag::new("common.yaml");
821        let result = splitter.split(spec_with_security);
822
823        // Security schemes should be preserved in the main spec
824        let main_components = result
825            .main
826            .components
827            .as_ref()
828            .expect("should have components");
829        assert!(main_components.security_schemes.contains_key("bearer_auth"));
830    }
831
832    #[test]
833    fn should_skip_operations_without_tags() {
834        let user_schema = ObjectBuilder::new()
835            .property(
836                "id",
837                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
838            )
839            .build();
840
841        let untagged_schema = ObjectBuilder::new()
842            .property(
843                "data",
844                ObjectBuilder::new().schema_type(utoipa::openapi::Type::String),
845            )
846            .build();
847
848        let mut components = Components::new();
849        components
850            .schemas
851            .insert("User".to_string(), RefOr::T(user_schema.into()));
852        components
853            .schemas
854            .insert("Untagged".to_string(), RefOr::T(untagged_schema.into()));
855
856        // Operation WITH tags
857        let get_user = OperationBuilder::new()
858            .tags(Some(vec!["users".to_string()]))
859            .response(
860                "200",
861                ResponseBuilder::new()
862                    .content(
863                        "application/json",
864                        ContentBuilder::new()
865                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/User"))))
866                            .build(),
867                    )
868                    .build(),
869            )
870            .build();
871
872        // Operation WITHOUT tags
873        let get_health = OperationBuilder::new()
874            // No tags!
875            .response(
876                "200",
877                ResponseBuilder::new()
878                    .content(
879                        "application/json",
880                        ContentBuilder::new()
881                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/Untagged"))))
882                            .build(),
883                    )
884                    .build(),
885            )
886            .build();
887
888        let mut paths = utoipa::openapi::Paths::new();
889        paths.paths.insert(
890            "/users".to_string(),
891            PathItemBuilder::new()
892                .operation(utoipa::openapi::HttpMethod::Get, get_user)
893                .build(),
894        );
895        paths.paths.insert(
896            "/health".to_string(),
897            PathItemBuilder::new()
898                .operation(utoipa::openapi::HttpMethod::Get, get_health)
899                .build(),
900        );
901
902        let spec = OpenApiBuilder::new()
903            .paths(paths)
904            .components(Some(components))
905            .build();
906
907        let splitter = SplitSchemasByTag::new("common.yaml");
908        let usage = splitter.analyze_schema_usage(&spec);
909
910        // User should be tracked (has tags)
911        assert!(usage.contains_key("User"));
912
913        // Untagged should NOT be tracked (no tags on operation)
914        assert!(!usage.contains_key("Untagged"));
915    }
916
917    #[test]
918    fn should_handle_spec_without_components() {
919        // Spec with no components at all
920        let mut paths = utoipa::openapi::Paths::new();
921        paths.paths.insert(
922            "/health".to_string(),
923            PathItemBuilder::new()
924                .operation(
925                    utoipa::openapi::HttpMethod::Get,
926                    OperationBuilder::new()
927                        .tags(Some(vec!["health".to_string()]))
928                        .response("200", ResponseBuilder::new().description("OK").build())
929                        .build(),
930                )
931                .build(),
932        );
933
934        let spec = OpenApiBuilder::new().paths(paths).build();
935
936        let splitter = ExtractSchemasByPredicate::new("errors.yaml", |name| name.contains("Error"));
937        let result = splitter.split(spec);
938
939        // Should return unchanged (no components to split)
940        assert!(result.is_unsplit());
941    }
942
943    #[test]
944    fn should_collect_schemas_from_request_bodies() {
945        use utoipa::openapi::request_body::RequestBodyBuilder;
946
947        let create_user_schema = ObjectBuilder::new()
948            .property(
949                "name",
950                ObjectBuilder::new().schema_type(utoipa::openapi::Type::String),
951            )
952            .build();
953
954        let user_schema = ObjectBuilder::new()
955            .property(
956                "id",
957                ObjectBuilder::new().schema_type(utoipa::openapi::Type::Integer),
958            )
959            .build();
960
961        let mut components = Components::new();
962        components.schemas.insert(
963            "CreateUser".to_string(),
964            RefOr::T(create_user_schema.into()),
965        );
966        components
967            .schemas
968            .insert("User".to_string(), RefOr::T(user_schema.into()));
969
970        // POST operation with request body referencing a schema
971        let create_user = OperationBuilder::new()
972            .tags(Some(vec!["users".to_string()]))
973            .request_body(Some(
974                RequestBodyBuilder::new()
975                    .content(
976                        "application/json",
977                        ContentBuilder::new()
978                            .schema(Some(RefOr::Ref(Ref::new(
979                                "#/components/schemas/CreateUser",
980                            ))))
981                            .build(),
982                    )
983                    .build(),
984            ))
985            .response(
986                "201",
987                ResponseBuilder::new()
988                    .content(
989                        "application/json",
990                        ContentBuilder::new()
991                            .schema(Some(RefOr::Ref(Ref::new("#/components/schemas/User"))))
992                            .build(),
993                    )
994                    .build(),
995            )
996            .build();
997
998        let mut paths = utoipa::openapi::Paths::new();
999        paths.paths.insert(
1000            "/users".to_string(),
1001            PathItemBuilder::new()
1002                .operation(utoipa::openapi::HttpMethod::Post, create_user)
1003                .build(),
1004        );
1005
1006        let spec = OpenApiBuilder::new()
1007            .paths(paths)
1008            .components(Some(components))
1009            .build();
1010
1011        let splitter = SplitSchemasByTag::new("common.yaml");
1012        let usage = splitter.analyze_schema_usage(&spec);
1013
1014        // Both CreateUser (from request body) and User (from response) should be tracked
1015        assert!(
1016            usage
1017                .get("CreateUser")
1018                .map(|t| t.contains("users"))
1019                .unwrap_or(false)
1020        );
1021        assert!(
1022            usage
1023                .get("User")
1024                .map(|t| t.contains("users"))
1025                .unwrap_or(false)
1026        );
1027    }
1028
1029    #[test]
1030    fn should_place_files_in_schemas_dir() {
1031        let spec = create_test_spec();
1032
1033        let splitter = SplitSchemasByTag::new("common.yaml").with_schemas_dir("schemas");
1034        let result = splitter.split(spec);
1035
1036        // All fragment files should be in the schemas directory
1037        for fragment in &result.fragments {
1038            assert!(
1039                fragment.path.starts_with("schemas"),
1040                "Fragment path {:?} should start with 'schemas'",
1041                fragment.path
1042            );
1043        }
1044
1045        // Check that common.yaml is also in the schemas directory
1046        let common_fragment = result.fragments.iter().find(|f| {
1047            f.path
1048                .file_name()
1049                .map(|n| n.to_string_lossy().contains("common"))
1050                .unwrap_or(false)
1051        });
1052        if let Some(fragment) = common_fragment {
1053            assert_eq!(
1054                fragment.path,
1055                PathBuf::from("schemas/common.yaml"),
1056                "Common file should be in schemas directory"
1057            );
1058        }
1059    }
1060}