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