Skip to main content

ploidy_codegen_rust/
cfg.rs

1//! Feature-gating for conditional compilation.
2//!
3//! Ploidy infers Cargo features from resource markers (`x-resourceId` on types;
4//! `x-resource-name` on operations), and propagates them forward and backward.
5//!
6//! In **forward propagation**, `x-resourceId` fields become `#[cfg(...)]` attributes
7//! on types and the operations that use them. Transitivity is handled by
8//! Cargo feature dependencies: for example, if `Customer` depends on `Address`,
9//! the `customer` feature enables the `address` feature in `Cargo.toml`, and
10//! the attribute reduces to `#[cfg(feature = "customer")]`.
11//! This is the style used by [Stripe's OpenAPI spec][stripe].
12//!
13//! In **backward propagation**, `x-resource-name` fields become `#[cfg(...)]` attributes
14//! on operations and the types they depend on. Each type needs at least one of
15//! the features of the operations that use it: for example,
16//! `#[cfg(any(feature = "orders", feature = "billing"))]`.
17//!
18//! When a spec mixes both styles, types can both have an own resource, and be used by
19//! operations. This produces compound predicates like
20//! `#[cfg(all(feature = "customer", any(feature = "orders", feature = "billing")))]`.
21//!
22//! [stripe]: https://github.com/stripe/openapi
23
24use std::collections::BTreeSet;
25
26use ploidy_core::ir::{HasResource, InlineTypeView, OperationView, SchemaTypeView, View};
27use proc_macro2::TokenStream;
28use quote::{ToTokens, quote};
29
30use super::{
31    graph::CodegenGraph,
32    naming::{AsFeatureName, ResourceGroup, UniqueIdent},
33};
34
35/// Generates a `#[cfg(...)]` attribute for conditional compilation.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum CfgFeature<'a> {
38    /// A single `feature = "name"` predicate.
39    Single(&'a UniqueIdent),
40    /// A compound `any(feature = "a", feature = "b", ...)` predicate.
41    AnyOf(BTreeSet<&'a UniqueIdent>),
42    /// A compound `all(feature = "a", feature = "b", ...)` predicate.
43    AllOf(BTreeSet<&'a UniqueIdent>),
44    /// A compound `all(feature = "own", any(feature = "a", ...))` predicate,
45    /// used for schema types that both specify an `x-resourceId`, and are
46    /// used by operations that specify an `x-resource-name`.
47    OwnAndUsedBy {
48        own: &'a UniqueIdent,
49        used_by: BTreeSet<&'a UniqueIdent>,
50    },
51}
52
53impl<'a> CfgFeature<'a> {
54    /// Builds a `#[cfg(...)]` attribute for a schema type, based on
55    /// its own resource, and the resources of the operations that use it.
56    pub fn for_schema_type(
57        graph: &CodegenGraph<'a>,
58        view: &SchemaTypeView<'_, 'a>,
59    ) -> Option<Self> {
60        // Types in the default resource group aren't feature-gated.
61        // If this type is in the default group, or is depended on by a type
62        // in that group, then it can't have a feature gate, either.
63        if in_default_resource_group(graph, view)
64            || view
65                .dependents()
66                .filter_map(|ty| ty.into_schema().right())
67                .any(|schema| in_default_resource_group(graph, &schema))
68        {
69            return None;
70        }
71
72        // Compute all the operations with resources that use this type.
73        let used_by_resources: BTreeSet<_> = view
74            .used_by()
75            .filter_map(|op| graph.resource_for(&op).name())
76            .collect();
77
78        match (graph.resource_for(view), used_by_resources.is_empty()) {
79            // Type has own resource, _and_ is used by operations.
80            (ResourceGroup::Named(own), false) => {
81                Some(Self::own_and_used_by(own, used_by_resources))
82            }
83            // Type has own resource only (Stripe-style; no resources on operations).
84            (ResourceGroup::Named(own), true) => Some(Self::Single(own)),
85            // Type has no own resource, but is used by operations
86            // (resource annotation-style; no resources on types).
87            (ResourceGroup::Default, false) => Self::any_of(used_by_resources),
88            // No resource name; not used by any operation.
89            (ResourceGroup::Default, true) => None,
90        }
91    }
92
93    /// Builds a `#[cfg(...)]` attribute for an inline type.
94    pub fn for_inline_type(
95        graph: &CodegenGraph<'a>,
96        view: &InlineTypeView<'_, 'a>,
97    ) -> Option<Self> {
98        // If this type is depended on by a type in the default resource group,
99        // then it can't have a feature gate.
100        if view
101            .dependents()
102            .filter_map(|ty| ty.into_schema().right())
103            .any(|schema| in_default_resource_group(graph, &schema))
104        {
105            return None;
106        }
107
108        let used_by_resources: BTreeSet<_> = view
109            .used_by()
110            .filter_map(|op| {
111                use ResourceGroup::*;
112                match (graph.resource_for(view), graph.resource_for(&op)) {
113                    (Default, Named(resource)) => Some(resource),
114                    (Named(own), Named(resource)) if own != resource => Some(resource),
115                    _ => None,
116                }
117            })
118            .collect();
119
120        if used_by_resources.is_empty() {
121            // No operations use this inline type directly, so use its
122            // transitive dependencies for gating.
123            Self::for_transitive_dependencies(graph, view)
124        } else {
125            // Some operations use this inline type; use those operations for gating.
126            Self::any_of(used_by_resources)
127        }
128    }
129
130    /// Builds a `#[cfg(...)]` attribute for a client method.
131    pub fn for_operation(graph: &CodegenGraph<'a>, view: &OperationView<'_, 'a>) -> Option<Self> {
132        Self::for_transitive_dependencies(graph, view)
133    }
134
135    /// Builds a `#[cfg(...)]` attribute from a view's resource dependencies.
136    ///
137    /// Reduces the set of resource features in a view's dependencies by
138    /// removing features that are transitively implied by other features.
139    /// If feature A's type depends on feature B's type, then enabling A
140    /// in `Cargo.toml` already enables B, so B is redundant.
141    fn for_transitive_dependencies<'graph>(
142        graph: &'graph CodegenGraph<'a>,
143        view: &(impl HasResource<'a> + View<'graph, 'a>),
144    ) -> Option<Self> {
145        Self::all_of(
146            view.dependencies()
147                .filter_map(|ty| {
148                    let schema = ty.into_schema().right()?;
149                    // Filter out dependencies without a resource name,
150                    // because these aren't feature-gated.
151                    let resource = graph.resource_for(&schema).name()?;
152                    // Keep our resource feature unless the other schema
153                    // depends on it, meaning that the other feature already
154                    // enables ours. If this schema and the other schema
155                    // depend on each other, the lexicographically lower
156                    // resource feature breaks the tie.
157                    let implied = view
158                        .dependencies()
159                        .filter_map(|ty| ty.into_schema().right())
160                        .any(|other_schema| {
161                            let ResourceGroup::Named(other_resource) =
162                                graph.resource_for(&other_schema)
163                            else {
164                                return false;
165                            };
166                            other_schema.depends_on(&schema)
167                                && (!schema.depends_on(&other_schema) || other_resource < resource)
168                        });
169                    (!implied).then_some(resource)
170                })
171                .filter(|&resource| match graph.resource_for(view) {
172                    ResourceGroup::Default => true,
173                    ResourceGroup::Named(own) if own != resource => true,
174                    _ => false,
175                })
176                .collect(),
177        )
178    }
179
180    /// Builds a `#[cfg(any(...))]` predicate, simplifying if possible.
181    fn any_of(mut features: BTreeSet<&'a UniqueIdent>) -> Option<Self> {
182        let first = features.pop_first()?;
183        Some(if features.is_empty() {
184            // Simplify `any(first)` to `first`.
185            Self::Single(first)
186        } else {
187            features.insert(first);
188            Self::AnyOf(features)
189        })
190    }
191
192    /// Builds a `#[cfg(all(...))]` predicate, simplifying if possible.
193    fn all_of(mut features: BTreeSet<&'a UniqueIdent>) -> Option<Self> {
194        let first = features.pop_first()?;
195        Some(if features.is_empty() {
196            // Simplify `all(first)` to `first`.
197            Self::Single(first)
198        } else {
199            features.insert(first);
200            Self::AllOf(features)
201        })
202    }
203
204    /// Builds a `#[cfg(all(own, any(...)))]` predicate, simplifying if possible.
205    fn own_and_used_by(own: &'a UniqueIdent, mut used_by: BTreeSet<&'a UniqueIdent>) -> Self {
206        if used_by.contains(own) {
207            // Simplify `all(own, any(own, ...))` to `own`.
208            return Self::Single(own);
209        }
210        let Some(first) = used_by.pop_first() else {
211            // Simplify `all(own)` to `own`.
212            return Self::Single(own);
213        };
214        if used_by.is_empty() {
215            // Simplify `all(own, any(first))` to `all(own, first)`.
216            Self::AllOf(BTreeSet::from_iter([own, first]))
217        } else {
218            // Keep `all(own, any(first, used_by...))`.
219            used_by.insert(first);
220            Self::OwnAndUsedBy { own, used_by }
221        }
222    }
223}
224
225impl ToTokens for CfgFeature<'_> {
226    fn to_tokens(&self, tokens: &mut TokenStream) {
227        let predicate = match self {
228            Self::Single(resource) => {
229                let name = AsFeatureName(resource).to_string();
230                quote! { feature = #name }
231            }
232            Self::AnyOf(resources) => {
233                let predicates = resources.iter().map(|f| {
234                    let name = AsFeatureName(f).to_string();
235                    quote! { feature = #name }
236                });
237                quote! { any(#(#predicates),*) }
238            }
239            Self::AllOf(resources) => {
240                let predicates = resources.iter().map(|f| {
241                    let name = AsFeatureName(f).to_string();
242                    quote! { feature = #name }
243                });
244                quote! { all(#(#predicates),*) }
245            }
246            Self::OwnAndUsedBy { own, used_by } => {
247                let own_name = AsFeatureName(own).to_string();
248                let used_by_predicates = used_by.iter().map(|f| {
249                    let name = AsFeatureName(f).to_string();
250                    quote! { feature = #name }
251                });
252                quote! { all(feature = #own_name, any(#(#used_by_predicates),*)) }
253            }
254        };
255        tokens.extend(quote! { #[cfg(#predicate)] });
256    }
257}
258
259fn in_default_resource_group<'graph, 'a>(
260    graph: &'graph CodegenGraph<'a>,
261    view: &(impl View<'graph, 'a> + HasResource<'a>),
262) -> bool {
263    let mut used_by = view.used_by().map(|op| graph.resource_for(&op)).peekable();
264    graph.resource_for(view).is_default()
265        && (used_by.peek().is_none() || used_by.any(|resource| resource.is_default()))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    use itertools::Itertools;
273    use ploidy_core::{
274        arena::Arena,
275        ir::{RawGraph, Spec},
276        parse::Document,
277    };
278    use pretty_assertions::assert_eq;
279    use syn::parse_quote;
280
281    use crate::{UniqueIdents, graph::CodegenGraph};
282
283    // MARK: Predicates
284
285    #[test]
286    fn test_single_feature() {
287        let arena = Arena::new();
288        let mut scope = UniqueIdents::new(&arena);
289        let cfg = CfgFeature::Single(scope.ident("pets"));
290
291        let actual: syn::Attribute = parse_quote!(#cfg);
292        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
293        assert_eq!(actual, expected);
294    }
295
296    #[test]
297    fn test_any_of_features() {
298        let arena = Arena::new();
299        let mut scope = UniqueIdents::new(&arena);
300        let cfg = CfgFeature::AnyOf(BTreeSet::from_iter([
301            scope.ident("cats"),
302            scope.ident("dogs"),
303            scope.ident("aardvarks"),
304        ]));
305
306        let actual: syn::Attribute = parse_quote!(#cfg);
307        let expected: syn::Attribute =
308            parse_quote!(#[cfg(any(feature = "aardvarks", feature = "cats", feature = "dogs"))]);
309        assert_eq!(actual, expected);
310    }
311
312    #[test]
313    fn test_all_of_features() {
314        let arena = Arena::new();
315        let mut scope = UniqueIdents::new(&arena);
316        let cfg = CfgFeature::AllOf(BTreeSet::from_iter([
317            scope.ident("cats"),
318            scope.ident("dogs"),
319            scope.ident("aardvarks"),
320        ]));
321
322        let actual: syn::Attribute = parse_quote!(#cfg);
323        let expected: syn::Attribute =
324            parse_quote!(#[cfg(all(feature = "aardvarks", feature = "cats", feature = "dogs"))]);
325        assert_eq!(actual, expected);
326    }
327
328    #[test]
329    fn test_own_and_used_by_feature() {
330        let arena = Arena::new();
331        let mut scope = UniqueIdents::new(&arena);
332        let cfg = CfgFeature::OwnAndUsedBy {
333            own: scope.ident("own"),
334            used_by: BTreeSet::from_iter([scope.ident("a"), scope.ident("b")]),
335        };
336
337        let actual: syn::Attribute = parse_quote!(#cfg);
338        let expected: syn::Attribute =
339            parse_quote!(#[cfg(all(feature = "own", any(feature = "a", feature = "b")))]);
340        assert_eq!(actual, expected);
341    }
342
343    #[test]
344    fn test_any_of_simplifies_single_feature() {
345        let arena = Arena::new();
346        let mut scope = UniqueIdents::new(&arena);
347        let cfg = CfgFeature::any_of(BTreeSet::from_iter([scope.ident("pets")]));
348
349        let actual: syn::Attribute = parse_quote!(#cfg);
350        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
351        assert_eq!(actual, expected);
352    }
353
354    #[test]
355    fn test_all_of_simplifies_single_feature() {
356        let arena = Arena::new();
357        let mut scope = UniqueIdents::new(&arena);
358        let cfg = CfgFeature::all_of(BTreeSet::from_iter([scope.ident("pets")]));
359
360        let actual: syn::Attribute = parse_quote!(#cfg);
361        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
362        assert_eq!(actual, expected);
363    }
364
365    #[test]
366    fn test_any_of_returns_none_for_empty() {
367        let cfg = CfgFeature::any_of(BTreeSet::new());
368        assert_eq!(cfg, None);
369    }
370
371    #[test]
372    fn test_all_of_returns_none_for_empty() {
373        let cfg = CfgFeature::all_of(BTreeSet::new());
374        assert_eq!(cfg, None);
375    }
376
377    #[test]
378    fn test_own_and_used_by_simplifies_single_used_by_to_all_of() {
379        let arena = Arena::new();
380        let mut scope = UniqueIdents::new(&arena);
381        let own = scope.ident("own");
382        let other = scope.ident("other");
383        // `OwnedAndUsedBy` with one `used_by` feature should simplify to `AllOf`.
384        let cfg = CfgFeature::own_and_used_by(own, BTreeSet::from_iter([other]));
385        assert_eq!(cfg, CfgFeature::AllOf(BTreeSet::from_iter([other, own])),);
386    }
387
388    #[test]
389    fn test_own_and_used_by_simplifies_empty_to_single() {
390        let arena = Arena::new();
391        let mut scope = UniqueIdents::new(&arena);
392        let own = scope.ident("own");
393        // `OwnedAndUsedBy` with no `used_by` features should simplify to `Single`.
394        let cfg = CfgFeature::own_and_used_by(own, BTreeSet::new());
395        assert_eq!(cfg, CfgFeature::Single(own));
396    }
397
398    #[test]
399    fn test_own_and_used_by_simplifies_own_used_by_to_single() {
400        let arena = Arena::new();
401        let mut scope = UniqueIdents::new(&arena);
402        let own = scope.ident("own");
403        let other = scope.ident("other");
404        let cfg = CfgFeature::own_and_used_by(own, BTreeSet::from_iter([own, other]));
405        assert_eq!(cfg, CfgFeature::Single(own));
406    }
407
408    // MARK: Schema types
409
410    #[test]
411    fn test_for_schema_type_returns_empty_when_no_named_resources() {
412        // Spec with no `x-resourceId` or `x-resource-name`.
413        let doc = Document::from_yaml(indoc::indoc! {"
414            openapi: 3.0.0
415            info:
416              title: Test
417              version: 1.0.0
418            components:
419              schemas:
420                Customer:
421                  type: object
422                  properties:
423                    id:
424                      type: string
425        "})
426        .unwrap();
427
428        let arena = Arena::new();
429        let spec = Spec::from_doc(&arena, &doc).unwrap();
430        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
431
432        let customer = graph.schema("Customer").unwrap();
433
434        // Shouldn't generate any feature gates for graph without named resources.
435        let cfg = CfgFeature::for_schema_type(&graph, &customer);
436        assert_eq!(cfg, None);
437    }
438
439    // MARK: Stripe-style
440
441    #[test]
442    fn test_for_schema_type_with_own_resource_no_deps() {
443        let doc = Document::from_yaml(indoc::indoc! {"
444            openapi: 3.0.0
445            info:
446              title: Test
447              version: 1.0.0
448            components:
449              schemas:
450                Customer:
451                  type: object
452                  x-resourceId: customer
453                  properties:
454                    id:
455                      type: string
456        "})
457        .unwrap();
458
459        let arena = Arena::new();
460        let spec = Spec::from_doc(&arena, &doc).unwrap();
461        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
462
463        let customer = graph.schema("Customer").unwrap();
464        let cfg = CfgFeature::for_schema_type(&graph, &customer);
465
466        let actual: syn::Attribute = parse_quote!(#cfg);
467        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
468        assert_eq!(actual, expected);
469    }
470
471    #[test]
472    fn test_for_schema_type_with_own_resource_and_unnamed_deps() {
473        // `Customer` (with `x-resourceId`) depends on `Address` (no `x-resourceId`).
474        // `Customer` keeps its own feature gate; `Address` is ungated.
475        let doc = Document::from_yaml(indoc::indoc! {"
476            openapi: 3.0.0
477            info:
478              title: Test
479              version: 1.0.0
480            components:
481              schemas:
482                Customer:
483                  type: object
484                  x-resourceId: customer
485                  properties:
486                    address:
487                      $ref: '#/components/schemas/Address'
488                Address:
489                  type: object
490                  properties:
491                    street:
492                      type: string
493        "})
494        .unwrap();
495
496        let arena = Arena::new();
497        let spec = Spec::from_doc(&arena, &doc).unwrap();
498        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
499
500        // `Customer` should be gated.
501        let customer = graph.schema("Customer").unwrap();
502        let cfg = CfgFeature::for_schema_type(&graph, &customer);
503        let actual: syn::Attribute = parse_quote!(#cfg);
504        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
505        assert_eq!(actual, expected);
506
507        // `Address` should be ungated.
508        let address = graph.schema("Address").unwrap();
509        let cfg = CfgFeature::for_schema_type(&graph, &address);
510        assert_eq!(cfg, None);
511    }
512
513    #[test]
514    fn test_for_schema_type_with_resource_id_named_default() {
515        // `CodegenGraph` reserves `default` for the `default` Cargo feature,
516        // so a resource named "default" won't collide with it.
517        let doc = Document::from_yaml(indoc::indoc! {"
518            openapi: 3.0.0
519            info:
520              title: Test
521              version: 1.0.0
522            components:
523              schemas:
524                Default:
525                  type: object
526                  x-resourceId: default
527                  properties:
528                    value:
529                      type: string
530        "})
531        .unwrap();
532
533        let arena = Arena::new();
534        let spec = Spec::from_doc(&arena, &doc).unwrap();
535        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
536
537        let default_type = graph.schema("Default").unwrap();
538        let cfg = CfgFeature::for_schema_type(&graph, &default_type);
539
540        let actual: syn::Attribute = parse_quote!(#cfg);
541        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "default2")]);
542        assert_eq!(actual, expected);
543    }
544
545    // MARK: Resource annotation-style
546
547    #[test]
548    fn test_for_schema_type_used_by_single_operation() {
549        let doc = Document::from_yaml(indoc::indoc! {"
550            openapi: 3.0.0
551            info:
552              title: Test
553              version: 1.0.0
554            paths:
555              /customers:
556                get:
557                  operationId: listCustomers
558                  x-resource-name: customer
559                  responses:
560                    '200':
561                      description: OK
562                      content:
563                        application/json:
564                          schema:
565                            type: array
566                            items:
567                              $ref: '#/components/schemas/Customer'
568            components:
569              schemas:
570                Customer:
571                  type: object
572                  properties:
573                    id:
574                      type: string
575        "})
576        .unwrap();
577
578        let arena = Arena::new();
579        let spec = Spec::from_doc(&arena, &doc).unwrap();
580        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
581
582        let customer = graph.schema("Customer").unwrap();
583        let cfg = CfgFeature::for_schema_type(&graph, &customer);
584
585        let actual: syn::Attribute = parse_quote!(#cfg);
586        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
587        assert_eq!(actual, expected);
588    }
589
590    #[test]
591    fn test_for_schema_type_used_by_multiple_operations() {
592        let doc = Document::from_yaml(indoc::indoc! {"
593            openapi: 3.0.0
594            info:
595              title: Test
596              version: 1.0.0
597            paths:
598              /customers:
599                get:
600                  operationId: listCustomers
601                  x-resource-name: customer
602                  responses:
603                    '200':
604                      description: OK
605                      content:
606                        application/json:
607                          schema:
608                            type: array
609                            items:
610                              $ref: '#/components/schemas/Address'
611              /orders:
612                get:
613                  operationId: listOrders
614                  x-resource-name: orders
615                  responses:
616                    '200':
617                      description: OK
618                      content:
619                        application/json:
620                          schema:
621                            type: array
622                            items:
623                              $ref: '#/components/schemas/Address'
624            components:
625              schemas:
626                Address:
627                  type: object
628                  properties:
629                    street:
630                      type: string
631        "})
632        .unwrap();
633
634        let arena = Arena::new();
635        let spec = Spec::from_doc(&arena, &doc).unwrap();
636        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
637
638        let address = graph.schema("Address").unwrap();
639        let cfg = CfgFeature::for_schema_type(&graph, &address);
640
641        let actual: syn::Attribute = parse_quote!(#cfg);
642        let expected: syn::Attribute =
643            parse_quote!(#[cfg(any(feature = "customer", feature = "orders"))]);
644        assert_eq!(actual, expected);
645    }
646
647    // MARK: Mixed styles
648
649    #[test]
650    fn test_for_schema_type_with_own_and_used_by() {
651        let doc = Document::from_yaml(indoc::indoc! {"
652            openapi: 3.0.0
653            info:
654              title: Test
655              version: 1.0.0
656            paths:
657              /billing:
658                get:
659                  operationId: getBilling
660                  x-resource-name: billing
661                  responses:
662                    '200':
663                      description: OK
664                      content:
665                        application/json:
666                          schema:
667                            $ref: '#/components/schemas/Customer'
668            components:
669              schemas:
670                Customer:
671                  type: object
672                  x-resourceId: customer
673                  properties:
674                    id:
675                      type: string
676        "})
677        .unwrap();
678
679        let arena = Arena::new();
680        let spec = Spec::from_doc(&arena, &doc).unwrap();
681        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
682
683        let customer = graph.schema("Customer").unwrap();
684        let cfg = CfgFeature::for_schema_type(&graph, &customer);
685
686        let actual: syn::Attribute = parse_quote!(#cfg);
687        let expected: syn::Attribute =
688            parse_quote!(#[cfg(all(feature = "billing", feature = "customer"))]);
689        assert_eq!(actual, expected);
690    }
691
692    #[test]
693    fn test_for_schema_type_with_own_and_same_used_by_uses_single_feature() {
694        let doc = Document::from_yaml(indoc::indoc! {"
695            openapi: 3.0.0
696            info:
697              title: Test
698              version: 1.0.0
699            paths:
700              /defaults:
701                get:
702                  operationId: listDefaults
703                  x-resource-name: default
704                  responses:
705                    '200':
706                      description: OK
707                      content:
708                        application/json:
709                          schema:
710                            $ref: '#/components/schemas/Default'
711            components:
712              schemas:
713                Default:
714                  type: object
715                  x-resourceId: default
716                  properties:
717                    id:
718                      type: string
719        "})
720        .unwrap();
721
722        let arena = Arena::new();
723        let spec = Spec::from_doc(&arena, &doc).unwrap();
724        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
725
726        let default_type = graph.schema("Default").unwrap();
727        let cfg = CfgFeature::for_schema_type(&graph, &default_type);
728
729        let actual: syn::Attribute = parse_quote!(#cfg);
730        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "default2")]);
731        assert_eq!(actual, expected);
732    }
733
734    #[test]
735    fn test_for_schema_type_with_own_same_and_other_used_by_uses_single_feature() {
736        let doc = Document::from_yaml(indoc::indoc! {"
737            openapi: 3.0.0
738            info:
739              title: Test
740              version: 1.0.0
741            paths:
742              /customer:
743                get:
744                  operationId: getCustomer
745                  x-resource-name: customer
746                  responses:
747                    '200':
748                      description: OK
749                      content:
750                        application/json:
751                          schema:
752                            $ref: '#/components/schemas/Customer'
753              /billing:
754                get:
755                  operationId: getBilling
756                  x-resource-name: billing
757                  responses:
758                    '200':
759                      description: OK
760                      content:
761                        application/json:
762                          schema:
763                            $ref: '#/components/schemas/Customer'
764            components:
765              schemas:
766                Customer:
767                  type: object
768                  x-resourceId: customer
769                  properties:
770                    id:
771                      type: string
772        "})
773        .unwrap();
774
775        let arena = Arena::new();
776        let spec = Spec::from_doc(&arena, &doc).unwrap();
777        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
778
779        let customer = graph.schema("Customer").unwrap();
780        let cfg = CfgFeature::for_schema_type(&graph, &customer);
781
782        let actual: syn::Attribute = parse_quote!(#cfg);
783        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
784        assert_eq!(actual, expected);
785    }
786
787    #[test]
788    fn test_for_schema_type_with_own_and_multiple_used_by() {
789        let doc = Document::from_yaml(indoc::indoc! {"
790            openapi: 3.0.0
791            info:
792              title: Test
793              version: 1.0.0
794            paths:
795              /billing:
796                get:
797                  operationId: getBilling
798                  x-resource-name: billing
799                  responses:
800                    '200':
801                      description: OK
802                      content:
803                        application/json:
804                          schema:
805                            $ref: '#/components/schemas/Customer'
806              /orders:
807                get:
808                  operationId: getOrders
809                  x-resource-name: orders
810                  responses:
811                    '200':
812                      description: OK
813                      content:
814                        application/json:
815                          schema:
816                            $ref: '#/components/schemas/Customer'
817            components:
818              schemas:
819                Customer:
820                  type: object
821                  x-resourceId: customer
822                  properties:
823                    id:
824                      type: string
825        "})
826        .unwrap();
827
828        let arena = Arena::new();
829        let spec = Spec::from_doc(&arena, &doc).unwrap();
830        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
831
832        let customer = graph.schema("Customer").unwrap();
833        let cfg = CfgFeature::for_schema_type(&graph, &customer);
834
835        let actual: syn::Attribute = parse_quote!(#cfg);
836        let expected: syn::Attribute = parse_quote!(
837            #[cfg(all(feature = "customer", any(feature = "billing", feature = "orders")))]
838        );
839        assert_eq!(actual, expected);
840    }
841
842    #[test]
843    fn test_for_schema_type_with_own_used_by_and_unnamed_deps() {
844        // `Customer` (with `x-resourceId`) is used by `getBilling`, and depends on `Address`
845        // (no `x-resourceId`). `Address` is transitively used by the operation, so it inherits
846        // the operation's feature gate. `Customer` keeps its compound feature gate.
847        let doc = Document::from_yaml(indoc::indoc! {"
848            openapi: 3.0.0
849            info:
850              title: Test
851              version: 1.0.0
852            paths:
853              /billing:
854                get:
855                  operationId: getBilling
856                  x-resource-name: billing
857                  responses:
858                    '200':
859                      description: OK
860                      content:
861                        application/json:
862                          schema:
863                            $ref: '#/components/schemas/Customer'
864            components:
865              schemas:
866                Customer:
867                  type: object
868                  x-resourceId: customer
869                  properties:
870                    address:
871                      $ref: '#/components/schemas/Address'
872                Address:
873                  type: object
874                  properties:
875                    street:
876                      type: string
877        "})
878        .unwrap();
879
880        let arena = Arena::new();
881        let spec = Spec::from_doc(&arena, &doc).unwrap();
882        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
883
884        // `Customer` keeps its compound feature gate (own + used by).
885        let customer = graph.schema("Customer").unwrap();
886        let cfg = CfgFeature::for_schema_type(&graph, &customer);
887        let actual: syn::Attribute = parse_quote!(#cfg);
888        let expected: syn::Attribute =
889            parse_quote!(#[cfg(all(feature = "billing", feature = "customer"))]);
890        assert_eq!(actual, expected);
891
892        // `Address` has no `x-resourceId`, but is used by the operation transitively,
893        // so it inherits the operation's feature gate.
894        let address = graph.schema("Address").unwrap();
895        let cfg = CfgFeature::for_schema_type(&graph, &address);
896        let actual: syn::Attribute = parse_quote!(#cfg);
897        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "billing")]);
898        assert_eq!(actual, expected);
899    }
900
901    // MARK: Types without resources
902
903    #[test]
904    fn test_for_schema_type_unnamed_no_operations() {
905        // Spec has a named resource (`Customer`), but `Simple` has
906        // no `x-resourceId` and isn't used, so it shouldn't be gated.
907        let doc = Document::from_yaml(indoc::indoc! {"
908            openapi: 3.0.0
909            info:
910              title: Test
911              version: 1.0.0
912            components:
913              schemas:
914                Simple:
915                  type: object
916                  properties:
917                    id:
918                      type: string
919                Customer:
920                  type: object
921                  x-resourceId: customer
922                  properties:
923                    name:
924                      type: string
925        "})
926        .unwrap();
927
928        let arena = Arena::new();
929        let spec = Spec::from_doc(&arena, &doc).unwrap();
930        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
931
932        let simple = graph.schema("Simple").unwrap();
933        let cfg = CfgFeature::for_schema_type(&graph, &simple);
934
935        // Types without a resource, and without operations that use them,
936        // should be ungated.
937        assert_eq!(cfg, None);
938    }
939
940    #[test]
941    fn test_for_schema_type_used_by_unresourced_operation() {
942        // `Status` is only used by `healthCheck`, which doesn't have an
943        // `x-resource-name`. Even though the spec has other resourced
944        // operations, `Status` should be ungated because its only consumer
945        // is ungated.
946        let doc = Document::from_yaml(indoc::indoc! {"
947            openapi: 3.0.0
948            info:
949              title: Test
950              version: 1.0.0
951            paths:
952              /customers:
953                get:
954                  operationId: listCustomers
955                  x-resource-name: customer
956                  responses:
957                    '200':
958                      description: OK
959                      content:
960                        application/json:
961                          schema:
962                            $ref: '#/components/schemas/Customer'
963              /health:
964                get:
965                  operationId: healthCheck
966                  responses:
967                    '200':
968                      description: OK
969                      content:
970                        application/json:
971                          schema:
972                            $ref: '#/components/schemas/Status'
973            components:
974              schemas:
975                Customer:
976                  type: object
977                  properties:
978                    name:
979                      type: string
980                Status:
981                  type: object
982                  properties:
983                    ok:
984                      type: boolean
985        "})
986        .unwrap();
987
988        let arena = Arena::new();
989        let spec = Spec::from_doc(&arena, &doc).unwrap();
990        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
991
992        // `Customer` is used by a resourced operation; it should be gated.
993        let customer = graph.schema("Customer").unwrap();
994        let cfg = CfgFeature::for_schema_type(&graph, &customer);
995        let actual: syn::Attribute = parse_quote!(#cfg);
996        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
997        assert_eq!(actual, expected);
998
999        // `Status` is only used by an unresourced operation;
1000        // it should be ungated.
1001        let status = graph.schema("Status").unwrap();
1002        let cfg = CfgFeature::for_schema_type(&graph, &status);
1003        assert_eq!(cfg, None);
1004    }
1005
1006    #[test]
1007    fn test_for_schema_type_used_by_resourced_and_unresourced_operations() {
1008        // `Status` is used by both an unresourced operation and a resourced
1009        // operation. It must stay ungated for the unresourced operation.
1010        let doc = Document::from_yaml(indoc::indoc! {"
1011            openapi: 3.0.0
1012            info:
1013              title: Test
1014              version: 1.0.0
1015            paths:
1016              /health:
1017                get:
1018                  operationId: getHealth
1019                  responses:
1020                    '200':
1021                      description: OK
1022                      content:
1023                        application/json:
1024                          schema:
1025                            $ref: '#/components/schemas/Status'
1026              /billing/status:
1027                get:
1028                  operationId: getBillingStatus
1029                  x-resource-name: billing
1030                  responses:
1031                    '200':
1032                      description: OK
1033                      content:
1034                        application/json:
1035                          schema:
1036                            $ref: '#/components/schemas/Status'
1037            components:
1038              schemas:
1039                Status:
1040                  type: object
1041                  properties:
1042                    ok:
1043                      type: boolean
1044        "})
1045        .unwrap();
1046
1047        let arena = Arena::new();
1048        let spec = Spec::from_doc(&arena, &doc).unwrap();
1049        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1050
1051        let status = graph.schema("Status").unwrap();
1052        let cfg = CfgFeature::for_schema_type(&graph, &status);
1053
1054        assert_eq!(cfg, None);
1055    }
1056
1057    // MARK: Cycles with mixed resources
1058
1059    #[test]
1060    fn test_for_schema_type_cycle_with_mixed_resources() {
1061        // Type A (resource `a`) -> Type B (no resource) -> Type C (resource `c`) -> Type A.
1062        // Since B is ungated (no `x-resourceId`), and transitively depends on A and C,
1063        // A and C should also be ungated.
1064        let doc = Document::from_yaml(indoc::indoc! {"
1065            openapi: 3.0.0
1066            info:
1067              title: Test
1068              version: 1.0.0
1069            components:
1070              schemas:
1071                A:
1072                  type: object
1073                  x-resourceId: a
1074                  properties:
1075                    b:
1076                      $ref: '#/components/schemas/B'
1077                B:
1078                  type: object
1079                  properties:
1080                    c:
1081                      $ref: '#/components/schemas/C'
1082                C:
1083                  type: object
1084                  x-resourceId: c
1085                  properties:
1086                    a:
1087                      $ref: '#/components/schemas/A'
1088        "})
1089        .unwrap();
1090
1091        let arena = Arena::new();
1092        let spec = Spec::from_doc(&arena, &doc).unwrap();
1093        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1094
1095        // In a cycle involving B, all types become ungated, because
1096        // B depends on C, which depends on A, which depends on B.
1097        let a = graph.schema("A").unwrap();
1098        let cfg = CfgFeature::for_schema_type(&graph, &a);
1099        assert_eq!(cfg, None);
1100
1101        let b = graph.schema("B").unwrap();
1102        let cfg = CfgFeature::for_schema_type(&graph, &b);
1103        assert_eq!(cfg, None);
1104
1105        let c = graph.schema("C").unwrap();
1106        let cfg = CfgFeature::for_schema_type(&graph, &c);
1107        assert_eq!(cfg, None);
1108    }
1109
1110    #[test]
1111    fn test_for_schema_type_cycle_with_all_named_resources() {
1112        // Type A (resource `a`) -> Type B (resource `b`) -> Type C (resource `c`) -> Type A.
1113        // Each type gets its own feature; transitivity is handled by
1114        // Cargo feature dependencies.
1115        let doc = Document::from_yaml(indoc::indoc! {"
1116            openapi: 3.0.0
1117            info:
1118              title: Test
1119              version: 1.0.0
1120            components:
1121              schemas:
1122                A:
1123                  type: object
1124                  x-resourceId: a
1125                  properties:
1126                    b:
1127                      $ref: '#/components/schemas/B'
1128                B:
1129                  type: object
1130                  x-resourceId: b
1131                  properties:
1132                    c:
1133                      $ref: '#/components/schemas/C'
1134                C:
1135                  type: object
1136                  x-resourceId: c
1137                  properties:
1138                    a:
1139                      $ref: '#/components/schemas/A'
1140        "})
1141        .unwrap();
1142
1143        let arena = Arena::new();
1144        let spec = Spec::from_doc(&arena, &doc).unwrap();
1145        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1146
1147        // Each type uses just its own feature; Cargo feature dependencies
1148        // handle the transitive requirements.
1149        let a = graph.schema("A").unwrap();
1150        let cfg = CfgFeature::for_schema_type(&graph, &a);
1151        let actual: syn::Attribute = parse_quote!(#cfg);
1152        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1153        assert_eq!(actual, expected);
1154
1155        let b = graph.schema("B").unwrap();
1156        let cfg = CfgFeature::for_schema_type(&graph, &b);
1157        let actual: syn::Attribute = parse_quote!(#cfg);
1158        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "b")]);
1159        assert_eq!(actual, expected);
1160
1161        let c = graph.schema("C").unwrap();
1162        let cfg = CfgFeature::for_schema_type(&graph, &c);
1163        let actual: syn::Attribute = parse_quote!(#cfg);
1164        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "c")]);
1165        assert_eq!(actual, expected);
1166    }
1167
1168    // MARK: Inline types
1169
1170    #[test]
1171    fn test_for_inline_returns_empty_when_no_named_resources() {
1172        // Spec with no `x-resourceId` or `x-resource-name`.
1173        let doc = Document::from_yaml(indoc::indoc! {"
1174            openapi: 3.0.0
1175            info:
1176              title: Test API
1177              version: 1.0.0
1178            paths:
1179              /items:
1180                get:
1181                  operationId: getItems
1182                  parameters:
1183                    - name: filter
1184                      in: query
1185                      schema:
1186                        type: object
1187                        properties:
1188                          status:
1189                            type: string
1190                  responses:
1191                    '200':
1192                      description: OK
1193        "})
1194        .unwrap();
1195
1196        let arena = Arena::new();
1197        let spec = Spec::from_doc(&arena, &doc).unwrap();
1198        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1199
1200        let ops = graph.operations().collect_vec();
1201        let inlines = ops.iter().flat_map(|op| op.inlines()).collect_vec();
1202        assert!(!inlines.is_empty());
1203
1204        // Shouldn't generate any feature gates for graph without named resources.
1205        let cfg = CfgFeature::for_inline_type(&graph, &inlines[0]);
1206        assert_eq!(cfg, None);
1207    }
1208
1209    #[test]
1210    fn test_for_inline_type_used_by_own_resource_has_no_cfg() {
1211        let doc = Document::from_yaml(indoc::indoc! {"
1212            openapi: 3.0.0
1213            info:
1214              title: Test
1215              version: 1.0.0
1216            paths:
1217              /defaults:
1218                get:
1219                  operationId: listDefaults
1220                  x-resource-name: default
1221                  responses:
1222                    '200':
1223                      description: OK
1224                      content:
1225                        application/json:
1226                          schema:
1227                            type: object
1228                            properties:
1229                              value:
1230                                $ref: '#/components/schemas/Default'
1231            components:
1232              schemas:
1233                Default:
1234                  type: object
1235                  x-resourceId: default
1236                  properties:
1237                    id:
1238                      type: string
1239        "})
1240        .unwrap();
1241
1242        let arena = Arena::new();
1243        let spec = Spec::from_doc(&arena, &doc).unwrap();
1244        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1245
1246        let ops = graph.operations().collect_vec();
1247        let inlines = ops.iter().flat_map(|op| op.inlines()).collect_vec();
1248        assert!(!inlines.is_empty());
1249
1250        let cfg = CfgFeature::for_inline_type(&graph, &inlines[0]);
1251        assert_eq!(cfg, None);
1252    }
1253
1254    // MARK: Reduction
1255
1256    #[test]
1257    fn test_for_operation_reduces_transitive_chain() {
1258        // A -> B -> C, each with a resource. The operation uses A.
1259        // Since A depends on B and C, only `feature = "a"` is needed.
1260        let doc = Document::from_yaml(indoc::indoc! {"
1261            openapi: 3.0.0
1262            info:
1263              title: Test
1264              version: 1.0.0
1265            paths:
1266              /things:
1267                get:
1268                  operationId: getThings
1269                  responses:
1270                    '200':
1271                      description: OK
1272                      content:
1273                        application/json:
1274                          schema:
1275                            $ref: '#/components/schemas/A'
1276            components:
1277              schemas:
1278                A:
1279                  type: object
1280                  x-resourceId: a
1281                  properties:
1282                    b:
1283                      $ref: '#/components/schemas/B'
1284                B:
1285                  type: object
1286                  x-resourceId: b
1287                  properties:
1288                    c:
1289                      $ref: '#/components/schemas/C'
1290                C:
1291                  type: object
1292                  x-resourceId: c
1293                  properties:
1294                    value:
1295                      type: string
1296        "})
1297        .unwrap();
1298
1299        let arena = Arena::new();
1300        let spec = Spec::from_doc(&arena, &doc).unwrap();
1301        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1302
1303        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1304        let cfg = CfgFeature::for_operation(&graph, &op);
1305
1306        let actual: syn::Attribute = parse_quote!(#cfg);
1307        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1308        assert_eq!(actual, expected);
1309    }
1310
1311    #[test]
1312    fn test_for_operation_reduces_cycle() {
1313        // A -> B -> C -> A, all with resources. The operation uses A.
1314        // Since they're all part of the same cycle, only the
1315        // lexicographically lowest feature should be present.
1316        let doc = Document::from_yaml(indoc::indoc! {"
1317            openapi: 3.0.0
1318            info:
1319              title: Test
1320              version: 1.0.0
1321            paths:
1322              /things:
1323                get:
1324                  operationId: getThings
1325                  responses:
1326                    '200':
1327                      description: OK
1328                      content:
1329                        application/json:
1330                          schema:
1331                            $ref: '#/components/schemas/A'
1332            components:
1333              schemas:
1334                A:
1335                  type: object
1336                  x-resourceId: a
1337                  properties:
1338                    b:
1339                      $ref: '#/components/schemas/B'
1340                B:
1341                  type: object
1342                  x-resourceId: b
1343                  properties:
1344                    c:
1345                      $ref: '#/components/schemas/C'
1346                C:
1347                  type: object
1348                  x-resourceId: c
1349                  properties:
1350                    a:
1351                      $ref: '#/components/schemas/A'
1352        "})
1353        .unwrap();
1354
1355        let arena = Arena::new();
1356        let spec = Spec::from_doc(&arena, &doc).unwrap();
1357        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1358
1359        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1360        let cfg = CfgFeature::for_operation(&graph, &op);
1361
1362        // All three are in a cycle; the lowest feature name wins.
1363        let actual: syn::Attribute = parse_quote!(#cfg);
1364        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1365        assert_eq!(actual, expected);
1366    }
1367
1368    #[test]
1369    fn test_for_operation_keeps_independent_features() {
1370        // A and B are independent (no dependency between them), so
1371        // both features should be present.
1372        let doc = Document::from_yaml(indoc::indoc! {"
1373            openapi: 3.0.0
1374            info:
1375              title: Test
1376              version: 1.0.0
1377            paths:
1378              /things:
1379                get:
1380                  operationId: getThings
1381                  responses:
1382                    '200':
1383                      description: OK
1384                      content:
1385                        application/json:
1386                          schema:
1387                            type: object
1388                            properties:
1389                              a:
1390                                $ref: '#/components/schemas/A'
1391                              b:
1392                                $ref: '#/components/schemas/B'
1393            components:
1394              schemas:
1395                A:
1396                  type: object
1397                  x-resourceId: a
1398                  properties:
1399                    value:
1400                      type: string
1401                B:
1402                  type: object
1403                  x-resourceId: b
1404                  properties:
1405                    value:
1406                      type: string
1407        "})
1408        .unwrap();
1409
1410        let arena = Arena::new();
1411        let spec = Spec::from_doc(&arena, &doc).unwrap();
1412        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1413
1414        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1415        let cfg = CfgFeature::for_operation(&graph, &op);
1416
1417        let actual: syn::Attribute = parse_quote!(#cfg);
1418        let expected: syn::Attribute = parse_quote!(#[cfg(all(feature = "a", feature = "b"))]);
1419        assert_eq!(actual, expected);
1420    }
1421
1422    #[test]
1423    fn test_for_operation_reduces_partial_deps() {
1424        // A -> B, C independent; all three have resources. A depends on B, so
1425        // feature `b` is redundant, but `c` must be present.
1426        let doc = Document::from_yaml(indoc::indoc! {"
1427            openapi: 3.0.0
1428            info:
1429              title: Test
1430              version: 1.0.0
1431            paths:
1432              /things:
1433                get:
1434                  operationId: getThings
1435                  responses:
1436                    '200':
1437                      description: OK
1438                      content:
1439                        application/json:
1440                          schema:
1441                            type: object
1442                            properties:
1443                              a:
1444                                $ref: '#/components/schemas/A'
1445                              c:
1446                                $ref: '#/components/schemas/C'
1447            components:
1448              schemas:
1449                A:
1450                  type: object
1451                  x-resourceId: a
1452                  properties:
1453                    b:
1454                      $ref: '#/components/schemas/B'
1455                B:
1456                  type: object
1457                  x-resourceId: b
1458                  properties:
1459                    value:
1460                      type: string
1461                C:
1462                  type: object
1463                  x-resourceId: c
1464                  properties:
1465                    value:
1466                      type: string
1467        "})
1468        .unwrap();
1469
1470        let arena = Arena::new();
1471        let spec = Spec::from_doc(&arena, &doc).unwrap();
1472        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1473
1474        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1475        let cfg = CfgFeature::for_operation(&graph, &op);
1476
1477        let actual: syn::Attribute = parse_quote!(#cfg);
1478        let expected: syn::Attribute = parse_quote!(#[cfg(all(feature = "a", feature = "c"))]);
1479        assert_eq!(actual, expected);
1480    }
1481
1482    #[test]
1483    fn test_for_operation_reduces_diamond_deps() {
1484        // A -> B, A -> C, B -> D, C -> D. The operation uses A.
1485        // Since A depends on B and C (which both depend on D), only `a` should remain.
1486        let doc = Document::from_yaml(indoc::indoc! {"
1487            openapi: 3.0.0
1488            info:
1489              title: Test
1490              version: 1.0.0
1491            paths:
1492              /things:
1493                get:
1494                  operationId: getThings
1495                  responses:
1496                    '200':
1497                      description: OK
1498                      content:
1499                        application/json:
1500                          schema:
1501                            $ref: '#/components/schemas/A'
1502            components:
1503              schemas:
1504                A:
1505                  type: object
1506                  x-resourceId: a
1507                  properties:
1508                    b:
1509                      $ref: '#/components/schemas/B'
1510                    c:
1511                      $ref: '#/components/schemas/C'
1512                B:
1513                  type: object
1514                  x-resourceId: b
1515                  properties:
1516                    d:
1517                      $ref: '#/components/schemas/D'
1518                C:
1519                  type: object
1520                  x-resourceId: c
1521                  properties:
1522                    d:
1523                      $ref: '#/components/schemas/D'
1524                D:
1525                  type: object
1526                  x-resourceId: d
1527                  properties:
1528                    value:
1529                      type: string
1530        "})
1531        .unwrap();
1532
1533        let arena = Arena::new();
1534        let spec = Spec::from_doc(&arena, &doc).unwrap();
1535        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1536
1537        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1538        let cfg = CfgFeature::for_operation(&graph, &op);
1539
1540        // A transitively implies B, C, and D; only `a` should remain.
1541        let actual: syn::Attribute = parse_quote!(#cfg);
1542        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1543        assert_eq!(actual, expected);
1544    }
1545
1546    #[test]
1547    fn test_for_operation_with_no_types() {
1548        // An operation with no parameters, request body, or response body.
1549        let doc = Document::from_yaml(indoc::indoc! {"
1550            openapi: 3.0.0
1551            info:
1552              title: Test
1553              version: 1.0.0
1554            paths:
1555              /health:
1556                get:
1557                  operationId: healthCheck
1558                  responses:
1559                    '200':
1560                      description: OK
1561        "})
1562        .unwrap();
1563
1564        let arena = Arena::new();
1565        let spec = Spec::from_doc(&arena, &doc).unwrap();
1566        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1567
1568        let op = graph
1569            .operations()
1570            .find(|o| o.id() == "healthCheck")
1571            .unwrap();
1572        let cfg = CfgFeature::for_operation(&graph, &op);
1573
1574        // An operation with no type dependencies should have no feature gate.
1575        assert_eq!(cfg, None);
1576    }
1577
1578    #[test]
1579    fn test_for_inline_type_reduces_transitive_features() {
1580        // Inline type inside a response, with A -> B -> C chain.
1581        // Only `a` should remain after reduction.
1582        let doc = Document::from_yaml(indoc::indoc! {"
1583            openapi: 3.0.0
1584            info:
1585              title: Test
1586              version: 1.0.0
1587            paths:
1588              /things:
1589                get:
1590                  operationId: getThings
1591                  responses:
1592                    '200':
1593                      description: OK
1594                      content:
1595                        application/json:
1596                          schema:
1597                            type: object
1598                            properties:
1599                              a:
1600                                $ref: '#/components/schemas/A'
1601            components:
1602              schemas:
1603                A:
1604                  type: object
1605                  x-resourceId: a
1606                  properties:
1607                    b:
1608                      $ref: '#/components/schemas/B'
1609                B:
1610                  type: object
1611                  x-resourceId: b
1612                  properties:
1613                    c:
1614                      $ref: '#/components/schemas/C'
1615                C:
1616                  type: object
1617                  x-resourceId: c
1618                  properties:
1619                    value:
1620                      type: string
1621        "})
1622        .unwrap();
1623
1624        let arena = Arena::new();
1625        let spec = Spec::from_doc(&arena, &doc).unwrap();
1626        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1627
1628        let ops = graph.operations().collect_vec();
1629        let inlines = ops.iter().flat_map(|op| op.inlines()).collect_vec();
1630        assert!(!inlines.is_empty());
1631
1632        let cfg = CfgFeature::for_inline_type(&graph, &inlines[0]);
1633
1634        let actual: syn::Attribute = parse_quote!(#cfg);
1635        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1636        assert_eq!(actual, expected);
1637    }
1638}