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::{InlineIrTypeView, IrOperationView, IrTypeView, SchemaIrTypeView, 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: &SchemaIrTypeView<'_>) -> 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(IrTypeView::as_schema)
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: &InlineIrTypeView<'_>) -> 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(IrTypeView::as_schema)
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(IrTypeView::as_schema)
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: &IrOperationView<'_>) -> Option<Self> {
126        // Collect all features from transitive dependencies, then
127        // reduce redundant features.
128        let pairs = view
129            .dependencies()
130            .filter_map(IrTypeView::as_schema)
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, SchemaIrTypeView<'_>)],
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        ir::{IrGraph, IrSpec},
275        parse::Document,
276    };
277    use pretty_assertions::assert_eq;
278    use syn::parse_quote;
279
280    use crate::graph::CodegenGraph;
281
282    // MARK: Predicates
283
284    #[test]
285    fn test_single_feature() {
286        let cfg = CfgFeature::Single(CargoFeature::from_name("pets"));
287
288        let actual: syn::Attribute = parse_quote!(#cfg);
289        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
290        assert_eq!(actual, expected);
291    }
292
293    #[test]
294    fn test_any_of_features() {
295        let cfg = CfgFeature::AnyOf(BTreeSet::from_iter([
296            CargoFeature::from_name("cats"),
297            CargoFeature::from_name("dogs"),
298            CargoFeature::from_name("aardvarks"),
299        ]));
300
301        let actual: syn::Attribute = parse_quote!(#cfg);
302        let expected: syn::Attribute =
303            parse_quote!(#[cfg(any(feature = "aardvarks", feature = "cats", feature = "dogs"))]);
304        assert_eq!(actual, expected);
305    }
306
307    #[test]
308    fn test_all_of_features() {
309        let cfg = CfgFeature::AllOf(BTreeSet::from_iter([
310            CargoFeature::from_name("cats"),
311            CargoFeature::from_name("dogs"),
312            CargoFeature::from_name("aardvarks"),
313        ]));
314
315        let actual: syn::Attribute = parse_quote!(#cfg);
316        let expected: syn::Attribute =
317            parse_quote!(#[cfg(all(feature = "aardvarks", feature = "cats", feature = "dogs"))]);
318        assert_eq!(actual, expected);
319    }
320
321    #[test]
322    fn test_own_and_used_by_feature() {
323        let cfg = CfgFeature::OwnAndUsedBy {
324            own: CargoFeature::from_name("own"),
325            used_by: BTreeSet::from_iter([
326                CargoFeature::from_name("a"),
327                CargoFeature::from_name("b"),
328            ]),
329        };
330
331        let actual: syn::Attribute = parse_quote!(#cfg);
332        let expected: syn::Attribute =
333            parse_quote!(#[cfg(all(feature = "own", any(feature = "a", feature = "b")))]);
334        assert_eq!(actual, expected);
335    }
336
337    #[test]
338    fn test_any_of_simplifies_single_feature() {
339        let cfg = CfgFeature::any_of(BTreeSet::from_iter([CargoFeature::from_name("pets")]));
340
341        let actual: syn::Attribute = parse_quote!(#cfg);
342        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
343        assert_eq!(actual, expected);
344    }
345
346    #[test]
347    fn test_all_of_simplifies_single_feature() {
348        let cfg = CfgFeature::all_of(BTreeSet::from_iter([CargoFeature::from_name("pets")]));
349
350        let actual: syn::Attribute = parse_quote!(#cfg);
351        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
352        assert_eq!(actual, expected);
353    }
354
355    #[test]
356    fn test_any_of_returns_none_for_empty() {
357        let cfg = CfgFeature::any_of(BTreeSet::new());
358        assert_eq!(cfg, None);
359    }
360
361    #[test]
362    fn test_any_of_returns_none_when_contains_default() {
363        // Items associated with the default resource shouldn't be gated.
364        let cfg = CfgFeature::any_of(BTreeSet::from_iter([
365            CargoFeature::from_name("customer"),
366            CargoFeature::Default,
367            CargoFeature::from_name("billing"),
368        ]));
369        assert_eq!(cfg, None);
370    }
371
372    #[test]
373    fn test_all_of_returns_none_for_empty() {
374        let cfg = CfgFeature::all_of(BTreeSet::new());
375        assert_eq!(cfg, None);
376    }
377
378    #[test]
379    fn test_all_of_returns_none_when_contains_default() {
380        // Items associated with the default resource shouldn't be gated.
381        let cfg = CfgFeature::all_of(BTreeSet::from_iter([
382            CargoFeature::from_name("customer"),
383            CargoFeature::Default,
384            CargoFeature::from_name("billing"),
385        ]));
386        assert_eq!(cfg, None);
387    }
388
389    #[test]
390    fn test_own_and_used_by_simplifies_single_used_by_to_all_of() {
391        // `OwnedAndUsedBy` with one `used_by` feature should simplify to `AllOf`.
392        let cfg = CfgFeature::own_and_used_by(
393            CargoFeature::from_name("own"),
394            BTreeSet::from_iter([CargoFeature::from_name("other")]),
395        );
396        assert_eq!(
397            cfg,
398            Some(CfgFeature::AllOf(BTreeSet::from_iter([
399                CargoFeature::from_name("other"),
400                CargoFeature::from_name("own"),
401            ])))
402        );
403    }
404
405    #[test]
406    fn test_own_and_used_by_simplifies_empty_to_single() {
407        // `OwnedAndUsedBy` with no `used_by` features should simplify to `Single`.
408        let cfg = CfgFeature::own_and_used_by(CargoFeature::from_name("own"), BTreeSet::new());
409        assert_eq!(
410            cfg,
411            Some(CfgFeature::Single(CargoFeature::from_name("own")))
412        );
413    }
414
415    #[test]
416    fn test_own_and_used_by_returns_none_when_own_is_default() {
417        // Items associated with the default resource shouldn't be gated.
418        let cfg = CfgFeature::own_and_used_by(
419            CargoFeature::Default,
420            BTreeSet::from_iter([CargoFeature::from_name("other")]),
421        );
422        assert_eq!(cfg, None);
423    }
424
425    #[test]
426    fn test_own_and_used_by_returns_none_when_used_by_contains_default() {
427        // Items associated with the default resource shouldn't be gated.
428        let cfg = CfgFeature::own_and_used_by(
429            CargoFeature::from_name("own"),
430            BTreeSet::from_iter([CargoFeature::from_name("other"), CargoFeature::Default]),
431        );
432        assert_eq!(cfg, None);
433    }
434
435    // MARK: Schema types
436
437    #[test]
438    fn test_for_schema_type_returns_empty_when_no_named_resources() {
439        // Spec with no `x-resourceId` or `x-resource-name`.
440        let doc = Document::from_yaml(indoc::indoc! {"
441            openapi: 3.0.0
442            info:
443              title: Test
444              version: 1.0.0
445            components:
446              schemas:
447                Customer:
448                  type: object
449                  properties:
450                    id:
451                      type: string
452        "})
453        .unwrap();
454
455        let spec = IrSpec::from_doc(&doc).unwrap();
456        let ir_graph = IrGraph::new(&spec);
457        let graph = CodegenGraph::new(ir_graph);
458
459        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
460
461        // Shouldn't generate any feature gates for graph without named resources.
462        let cfg = CfgFeature::for_schema_type(&customer);
463        assert_eq!(cfg, None);
464    }
465
466    // MARK: Stripe-style
467
468    #[test]
469    fn test_for_schema_type_with_own_resource_no_deps() {
470        let doc = Document::from_yaml(indoc::indoc! {"
471            openapi: 3.0.0
472            info:
473              title: Test
474              version: 1.0.0
475            components:
476              schemas:
477                Customer:
478                  type: object
479                  x-resourceId: customer
480                  properties:
481                    id:
482                      type: string
483        "})
484        .unwrap();
485
486        let spec = IrSpec::from_doc(&doc).unwrap();
487        let ir_graph = IrGraph::new(&spec);
488        let graph = CodegenGraph::new(ir_graph);
489
490        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
491        let cfg = CfgFeature::for_schema_type(&customer);
492
493        let actual: syn::Attribute = parse_quote!(#cfg);
494        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
495        assert_eq!(actual, expected);
496    }
497
498    #[test]
499    fn test_for_schema_type_with_own_resource_and_unnamed_deps() {
500        // `Customer` (with `x-resourceId`) depends on `Address` (no `x-resourceId`).
501        // `Customer` keeps its own feature gate; `Address` is ungated.
502        let doc = Document::from_yaml(indoc::indoc! {"
503            openapi: 3.0.0
504            info:
505              title: Test
506              version: 1.0.0
507            components:
508              schemas:
509                Customer:
510                  type: object
511                  x-resourceId: customer
512                  properties:
513                    address:
514                      $ref: '#/components/schemas/Address'
515                Address:
516                  type: object
517                  properties:
518                    street:
519                      type: string
520        "})
521        .unwrap();
522
523        let spec = IrSpec::from_doc(&doc).unwrap();
524        let ir_graph = IrGraph::new(&spec);
525        let graph = CodegenGraph::new(ir_graph);
526
527        // `Customer` should be gated.
528        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
529        let cfg = CfgFeature::for_schema_type(&customer);
530        let actual: syn::Attribute = parse_quote!(#cfg);
531        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
532        assert_eq!(actual, expected);
533
534        // `Address` should be ungated.
535        let address = graph.schemas().find(|s| s.name() == "Address").unwrap();
536        let cfg = CfgFeature::for_schema_type(&address);
537        assert_eq!(cfg, None);
538    }
539
540    // MARK: Resource annotation-style
541
542    #[test]
543    fn test_for_schema_type_used_by_single_operation() {
544        let doc = Document::from_yaml(indoc::indoc! {"
545            openapi: 3.0.0
546            info:
547              title: Test
548              version: 1.0.0
549            paths:
550              /customers:
551                get:
552                  operationId: listCustomers
553                  x-resource-name: customer
554                  responses:
555                    '200':
556                      description: OK
557                      content:
558                        application/json:
559                          schema:
560                            type: array
561                            items:
562                              $ref: '#/components/schemas/Customer'
563            components:
564              schemas:
565                Customer:
566                  type: object
567                  properties:
568                    id:
569                      type: string
570        "})
571        .unwrap();
572
573        let spec = IrSpec::from_doc(&doc).unwrap();
574        let ir_graph = IrGraph::new(&spec);
575        let graph = CodegenGraph::new(ir_graph);
576
577        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
578        let cfg = CfgFeature::for_schema_type(&customer);
579
580        let actual: syn::Attribute = parse_quote!(#cfg);
581        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "customer")]);
582        assert_eq!(actual, expected);
583    }
584
585    #[test]
586    fn test_for_schema_type_used_by_multiple_operations() {
587        let doc = Document::from_yaml(indoc::indoc! {"
588            openapi: 3.0.0
589            info:
590              title: Test
591              version: 1.0.0
592            paths:
593              /customers:
594                get:
595                  operationId: listCustomers
596                  x-resource-name: customer
597                  responses:
598                    '200':
599                      description: OK
600                      content:
601                        application/json:
602                          schema:
603                            type: array
604                            items:
605                              $ref: '#/components/schemas/Address'
606              /orders:
607                get:
608                  operationId: listOrders
609                  x-resource-name: orders
610                  responses:
611                    '200':
612                      description: OK
613                      content:
614                        application/json:
615                          schema:
616                            type: array
617                            items:
618                              $ref: '#/components/schemas/Address'
619            components:
620              schemas:
621                Address:
622                  type: object
623                  properties:
624                    street:
625                      type: string
626        "})
627        .unwrap();
628
629        let spec = IrSpec::from_doc(&doc).unwrap();
630        let ir_graph = IrGraph::new(&spec);
631        let graph = CodegenGraph::new(ir_graph);
632
633        let address = graph.schemas().find(|s| s.name() == "Address").unwrap();
634        let cfg = CfgFeature::for_schema_type(&address);
635
636        let actual: syn::Attribute = parse_quote!(#cfg);
637        let expected: syn::Attribute =
638            parse_quote!(#[cfg(any(feature = "customer", feature = "orders"))]);
639        assert_eq!(actual, expected);
640    }
641
642    // MARK: Mixed styles
643
644    #[test]
645    fn test_for_schema_type_with_own_and_used_by() {
646        let doc = Document::from_yaml(indoc::indoc! {"
647            openapi: 3.0.0
648            info:
649              title: Test
650              version: 1.0.0
651            paths:
652              /billing:
653                get:
654                  operationId: getBilling
655                  x-resource-name: billing
656                  responses:
657                    '200':
658                      description: OK
659                      content:
660                        application/json:
661                          schema:
662                            $ref: '#/components/schemas/Customer'
663            components:
664              schemas:
665                Customer:
666                  type: object
667                  x-resourceId: customer
668                  properties:
669                    id:
670                      type: string
671        "})
672        .unwrap();
673
674        let spec = IrSpec::from_doc(&doc).unwrap();
675        let ir_graph = IrGraph::new(&spec);
676        let graph = CodegenGraph::new(ir_graph);
677
678        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
679        let cfg = CfgFeature::for_schema_type(&customer);
680
681        let actual: syn::Attribute = parse_quote!(#cfg);
682        let expected: syn::Attribute =
683            parse_quote!(#[cfg(all(feature = "billing", feature = "customer"))]);
684        assert_eq!(actual, expected);
685    }
686
687    #[test]
688    fn test_for_schema_type_with_own_and_multiple_used_by() {
689        let doc = Document::from_yaml(indoc::indoc! {"
690            openapi: 3.0.0
691            info:
692              title: Test
693              version: 1.0.0
694            paths:
695              /billing:
696                get:
697                  operationId: getBilling
698                  x-resource-name: billing
699                  responses:
700                    '200':
701                      description: OK
702                      content:
703                        application/json:
704                          schema:
705                            $ref: '#/components/schemas/Customer'
706              /orders:
707                get:
708                  operationId: getOrders
709                  x-resource-name: orders
710                  responses:
711                    '200':
712                      description: OK
713                      content:
714                        application/json:
715                          schema:
716                            $ref: '#/components/schemas/Customer'
717            components:
718              schemas:
719                Customer:
720                  type: object
721                  x-resourceId: customer
722                  properties:
723                    id:
724                      type: string
725        "})
726        .unwrap();
727
728        let spec = IrSpec::from_doc(&doc).unwrap();
729        let ir_graph = IrGraph::new(&spec);
730        let graph = CodegenGraph::new(ir_graph);
731
732        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
733        let cfg = CfgFeature::for_schema_type(&customer);
734
735        let actual: syn::Attribute = parse_quote!(#cfg);
736        let expected: syn::Attribute = parse_quote!(
737            #[cfg(all(feature = "customer", any(feature = "billing", feature = "orders")))]
738        );
739        assert_eq!(actual, expected);
740    }
741
742    #[test]
743    fn test_for_schema_type_with_own_used_by_and_unnamed_deps() {
744        // `Customer` (with `x-resourceId`) is used by `getBilling`, and depends on `Address`
745        // (no `x-resourceId`). `Address` is transitively used by the operation, so it inherits
746        // the operation's feature gate. `Customer` keeps its compound feature gate.
747        let doc = Document::from_yaml(indoc::indoc! {"
748            openapi: 3.0.0
749            info:
750              title: Test
751              version: 1.0.0
752            paths:
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                    address:
771                      $ref: '#/components/schemas/Address'
772                Address:
773                  type: object
774                  properties:
775                    street:
776                      type: string
777        "})
778        .unwrap();
779
780        let spec = IrSpec::from_doc(&doc).unwrap();
781        let ir_graph = IrGraph::new(&spec);
782        let graph = CodegenGraph::new(ir_graph);
783
784        // `Customer` keeps its compound feature gate (own + used by).
785        let customer = graph.schemas().find(|s| s.name() == "Customer").unwrap();
786        let cfg = CfgFeature::for_schema_type(&customer);
787        let actual: syn::Attribute = parse_quote!(#cfg);
788        let expected: syn::Attribute =
789            parse_quote!(#[cfg(all(feature = "billing", feature = "customer"))]);
790        assert_eq!(actual, expected);
791
792        // `Address` has no `x-resourceId`, but is used by the operation transitively,
793        // so it inherits the operation's feature gate.
794        let address = graph.schemas().find(|s| s.name() == "Address").unwrap();
795        let cfg = CfgFeature::for_schema_type(&address);
796        let actual: syn::Attribute = parse_quote!(#cfg);
797        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "billing")]);
798        assert_eq!(actual, expected);
799    }
800
801    // MARK: Types without resources
802
803    #[test]
804    fn test_for_schema_type_unnamed_no_operations() {
805        // Spec has a named resource (`Customer`), but `Simple` has
806        // no `x-resourceId` and isn't used, so it shouldn't be gated.
807        let doc = Document::from_yaml(indoc::indoc! {"
808            openapi: 3.0.0
809            info:
810              title: Test
811              version: 1.0.0
812            components:
813              schemas:
814                Simple:
815                  type: object
816                  properties:
817                    id:
818                      type: string
819                Customer:
820                  type: object
821                  x-resourceId: customer
822                  properties:
823                    name:
824                      type: string
825        "})
826        .unwrap();
827
828        let spec = IrSpec::from_doc(&doc).unwrap();
829        let ir_graph = IrGraph::new(&spec);
830        let graph = CodegenGraph::new(ir_graph);
831
832        let simple = graph.schemas().find(|s| s.name() == "Simple").unwrap();
833        let cfg = CfgFeature::for_schema_type(&simple);
834
835        // Types without a resource, and without operations that use them,
836        // should be ungated.
837        assert_eq!(cfg, None);
838    }
839
840    // MARK: Cycles with mixed resources
841
842    #[test]
843    fn test_for_schema_type_cycle_with_mixed_resources() {
844        // Type A (resource `a`) -> Type B (no resource) -> Type C (resource `c`) -> Type A.
845        // Since B is ungated (no `x-resourceId`), and transitively depends on A and C,
846        // A and C should also be ungated.
847        let doc = Document::from_yaml(indoc::indoc! {"
848            openapi: 3.0.0
849            info:
850              title: Test
851              version: 1.0.0
852            components:
853              schemas:
854                A:
855                  type: object
856                  x-resourceId: a
857                  properties:
858                    b:
859                      $ref: '#/components/schemas/B'
860                B:
861                  type: object
862                  properties:
863                    c:
864                      $ref: '#/components/schemas/C'
865                C:
866                  type: object
867                  x-resourceId: c
868                  properties:
869                    a:
870                      $ref: '#/components/schemas/A'
871        "})
872        .unwrap();
873
874        let spec = IrSpec::from_doc(&doc).unwrap();
875        let ir_graph = IrGraph::new(&spec);
876        let graph = CodegenGraph::new(ir_graph);
877
878        // In a cycle involving B, all types become ungated, because
879        // B depends on C, which depends on A, which depends on B.
880        let a = graph.schemas().find(|s| s.name() == "A").unwrap();
881        let cfg = CfgFeature::for_schema_type(&a);
882        assert_eq!(cfg, None);
883
884        let b = graph.schemas().find(|s| s.name() == "B").unwrap();
885        let cfg = CfgFeature::for_schema_type(&b);
886        assert_eq!(cfg, None);
887
888        let c = graph.schemas().find(|s| s.name() == "C").unwrap();
889        let cfg = CfgFeature::for_schema_type(&c);
890        assert_eq!(cfg, None);
891    }
892
893    #[test]
894    fn test_for_schema_type_cycle_with_all_named_resources() {
895        // Type A (resource `a`) -> Type B (resource `b`) -> Type C (resource `c`) -> Type A.
896        // Each type gets its own feature; transitivity is handled by
897        // Cargo feature dependencies.
898        let doc = Document::from_yaml(indoc::indoc! {"
899            openapi: 3.0.0
900            info:
901              title: Test
902              version: 1.0.0
903            components:
904              schemas:
905                A:
906                  type: object
907                  x-resourceId: a
908                  properties:
909                    b:
910                      $ref: '#/components/schemas/B'
911                B:
912                  type: object
913                  x-resourceId: b
914                  properties:
915                    c:
916                      $ref: '#/components/schemas/C'
917                C:
918                  type: object
919                  x-resourceId: c
920                  properties:
921                    a:
922                      $ref: '#/components/schemas/A'
923        "})
924        .unwrap();
925
926        let spec = IrSpec::from_doc(&doc).unwrap();
927        let ir_graph = IrGraph::new(&spec);
928        let graph = CodegenGraph::new(ir_graph);
929
930        // Each type uses just its own feature; Cargo feature dependencies
931        // handle the transitive requirements.
932        let a = graph.schemas().find(|s| s.name() == "A").unwrap();
933        let cfg = CfgFeature::for_schema_type(&a);
934        let actual: syn::Attribute = parse_quote!(#cfg);
935        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
936        assert_eq!(actual, expected);
937
938        let b = graph.schemas().find(|s| s.name() == "B").unwrap();
939        let cfg = CfgFeature::for_schema_type(&b);
940        let actual: syn::Attribute = parse_quote!(#cfg);
941        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "b")]);
942        assert_eq!(actual, expected);
943
944        let c = graph.schemas().find(|s| s.name() == "C").unwrap();
945        let cfg = CfgFeature::for_schema_type(&c);
946        let actual: syn::Attribute = parse_quote!(#cfg);
947        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "c")]);
948        assert_eq!(actual, expected);
949    }
950
951    // MARK: Inline types
952
953    #[test]
954    fn test_for_inline_returns_empty_when_no_named_resources() {
955        // Spec with no `x-resourceId` or `x-resource-name`.
956        let doc = Document::from_yaml(indoc::indoc! {"
957            openapi: 3.0.0
958            info:
959              title: Test API
960              version: 1.0.0
961            paths:
962              /items:
963                get:
964                  operationId: getItems
965                  parameters:
966                    - name: filter
967                      in: query
968                      schema:
969                        type: object
970                        properties:
971                          status:
972                            type: string
973                  responses:
974                    '200':
975                      description: OK
976        "})
977        .unwrap();
978
979        let spec = IrSpec::from_doc(&doc).unwrap();
980        let ir_graph = IrGraph::new(&spec);
981        let graph = CodegenGraph::new(ir_graph);
982
983        let ops = graph.operations().collect_vec();
984        let inlines = ops.iter().flat_map(|op| op.inlines()).collect_vec();
985        assert!(!inlines.is_empty());
986
987        // Shouldn't generate any feature gates for graph without named resources.
988        let cfg = CfgFeature::for_inline_type(&inlines[0]);
989        assert_eq!(cfg, None);
990    }
991
992    // MARK: Resource modules
993
994    #[test]
995    fn test_for_resource_module_with_named_feature() {
996        let cfg = CfgFeature::for_resource_module(&CargoFeature::from_name("pets"));
997
998        let actual: syn::Attribute = parse_quote!(#cfg);
999        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "pets")]);
1000        assert_eq!(actual, expected);
1001    }
1002
1003    #[test]
1004    fn test_for_resource_module_skips_default_feature() {
1005        // The `default` feature is built in to Cargo. Gating a module
1006        // behind it makes operations unreachable when individual features
1007        // are enabled via `--no-default-features --features foo`.
1008        let cfg = CfgFeature::for_resource_module(&CargoFeature::Default);
1009        assert_eq!(cfg, None);
1010    }
1011
1012    // MARK: Reduction
1013
1014    #[test]
1015    fn test_for_operation_reduces_transitive_chain() {
1016        // A -> B -> C, each with a resource. The operation uses A.
1017        // Since A depends on B and C, only `feature = "a"` is needed.
1018        let doc = Document::from_yaml(indoc::indoc! {"
1019            openapi: 3.0.0
1020            info:
1021              title: Test
1022              version: 1.0.0
1023            paths:
1024              /things:
1025                get:
1026                  operationId: getThings
1027                  responses:
1028                    '200':
1029                      description: OK
1030                      content:
1031                        application/json:
1032                          schema:
1033                            $ref: '#/components/schemas/A'
1034            components:
1035              schemas:
1036                A:
1037                  type: object
1038                  x-resourceId: a
1039                  properties:
1040                    b:
1041                      $ref: '#/components/schemas/B'
1042                B:
1043                  type: object
1044                  x-resourceId: b
1045                  properties:
1046                    c:
1047                      $ref: '#/components/schemas/C'
1048                C:
1049                  type: object
1050                  x-resourceId: c
1051                  properties:
1052                    value:
1053                      type: string
1054        "})
1055        .unwrap();
1056
1057        let spec = IrSpec::from_doc(&doc).unwrap();
1058        let ir_graph = IrGraph::new(&spec);
1059        let graph = CodegenGraph::new(ir_graph);
1060
1061        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1062        let cfg = CfgFeature::for_operation(&op);
1063
1064        let actual: syn::Attribute = parse_quote!(#cfg);
1065        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1066        assert_eq!(actual, expected);
1067    }
1068
1069    #[test]
1070    fn test_for_operation_reduces_cycle() {
1071        // A -> B -> C -> A, all with resources. The operation uses A.
1072        // Since they're all part of the same cycle, only the
1073        // lexicographically lowest feature should be present.
1074        let doc = Document::from_yaml(indoc::indoc! {"
1075            openapi: 3.0.0
1076            info:
1077              title: Test
1078              version: 1.0.0
1079            paths:
1080              /things:
1081                get:
1082                  operationId: getThings
1083                  responses:
1084                    '200':
1085                      description: OK
1086                      content:
1087                        application/json:
1088                          schema:
1089                            $ref: '#/components/schemas/A'
1090            components:
1091              schemas:
1092                A:
1093                  type: object
1094                  x-resourceId: a
1095                  properties:
1096                    b:
1097                      $ref: '#/components/schemas/B'
1098                B:
1099                  type: object
1100                  x-resourceId: b
1101                  properties:
1102                    c:
1103                      $ref: '#/components/schemas/C'
1104                C:
1105                  type: object
1106                  x-resourceId: c
1107                  properties:
1108                    a:
1109                      $ref: '#/components/schemas/A'
1110        "})
1111        .unwrap();
1112
1113        let spec = IrSpec::from_doc(&doc).unwrap();
1114        let ir_graph = IrGraph::new(&spec);
1115        let graph = CodegenGraph::new(ir_graph);
1116
1117        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1118        let cfg = CfgFeature::for_operation(&op);
1119
1120        // All three are in a cycle; the lowest feature name wins.
1121        let actual: syn::Attribute = parse_quote!(#cfg);
1122        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1123        assert_eq!(actual, expected);
1124    }
1125
1126    #[test]
1127    fn test_for_operation_keeps_independent_features() {
1128        // A and B are independent (no dependency between them), so
1129        // both features should be present.
1130        let doc = Document::from_yaml(indoc::indoc! {"
1131            openapi: 3.0.0
1132            info:
1133              title: Test
1134              version: 1.0.0
1135            paths:
1136              /things:
1137                get:
1138                  operationId: getThings
1139                  responses:
1140                    '200':
1141                      description: OK
1142                      content:
1143                        application/json:
1144                          schema:
1145                            type: object
1146                            properties:
1147                              a:
1148                                $ref: '#/components/schemas/A'
1149                              b:
1150                                $ref: '#/components/schemas/B'
1151            components:
1152              schemas:
1153                A:
1154                  type: object
1155                  x-resourceId: a
1156                  properties:
1157                    value:
1158                      type: string
1159                B:
1160                  type: object
1161                  x-resourceId: b
1162                  properties:
1163                    value:
1164                      type: string
1165        "})
1166        .unwrap();
1167
1168        let spec = IrSpec::from_doc(&doc).unwrap();
1169        let ir_graph = IrGraph::new(&spec);
1170        let graph = CodegenGraph::new(ir_graph);
1171
1172        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1173        let cfg = CfgFeature::for_operation(&op);
1174
1175        let actual: syn::Attribute = parse_quote!(#cfg);
1176        let expected: syn::Attribute = parse_quote!(#[cfg(all(feature = "a", feature = "b"))]);
1177        assert_eq!(actual, expected);
1178    }
1179
1180    #[test]
1181    fn test_for_operation_reduces_partial_deps() {
1182        // A -> B, C independent; all three have resources. A depends on B, so
1183        // feature `b` is redundant, but `c` must be present.
1184        let doc = Document::from_yaml(indoc::indoc! {"
1185            openapi: 3.0.0
1186            info:
1187              title: Test
1188              version: 1.0.0
1189            paths:
1190              /things:
1191                get:
1192                  operationId: getThings
1193                  responses:
1194                    '200':
1195                      description: OK
1196                      content:
1197                        application/json:
1198                          schema:
1199                            type: object
1200                            properties:
1201                              a:
1202                                $ref: '#/components/schemas/A'
1203                              c:
1204                                $ref: '#/components/schemas/C'
1205            components:
1206              schemas:
1207                A:
1208                  type: object
1209                  x-resourceId: a
1210                  properties:
1211                    b:
1212                      $ref: '#/components/schemas/B'
1213                B:
1214                  type: object
1215                  x-resourceId: b
1216                  properties:
1217                    value:
1218                      type: string
1219                C:
1220                  type: object
1221                  x-resourceId: c
1222                  properties:
1223                    value:
1224                      type: string
1225        "})
1226        .unwrap();
1227
1228        let spec = IrSpec::from_doc(&doc).unwrap();
1229        let ir_graph = IrGraph::new(&spec);
1230        let graph = CodegenGraph::new(ir_graph);
1231
1232        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1233        let cfg = CfgFeature::for_operation(&op);
1234
1235        let actual: syn::Attribute = parse_quote!(#cfg);
1236        let expected: syn::Attribute = parse_quote!(#[cfg(all(feature = "a", feature = "c"))]);
1237        assert_eq!(actual, expected);
1238    }
1239
1240    #[test]
1241    fn test_for_operation_reduces_diamond_deps() {
1242        // A -> B, A -> C, B -> D, C -> D. The operation uses A.
1243        // Since A depends on B and C (which both depend on D), only `a` should remain.
1244        let doc = Document::from_yaml(indoc::indoc! {"
1245            openapi: 3.0.0
1246            info:
1247              title: Test
1248              version: 1.0.0
1249            paths:
1250              /things:
1251                get:
1252                  operationId: getThings
1253                  responses:
1254                    '200':
1255                      description: OK
1256                      content:
1257                        application/json:
1258                          schema:
1259                            $ref: '#/components/schemas/A'
1260            components:
1261              schemas:
1262                A:
1263                  type: object
1264                  x-resourceId: a
1265                  properties:
1266                    b:
1267                      $ref: '#/components/schemas/B'
1268                    c:
1269                      $ref: '#/components/schemas/C'
1270                B:
1271                  type: object
1272                  x-resourceId: b
1273                  properties:
1274                    d:
1275                      $ref: '#/components/schemas/D'
1276                C:
1277                  type: object
1278                  x-resourceId: c
1279                  properties:
1280                    d:
1281                      $ref: '#/components/schemas/D'
1282                D:
1283                  type: object
1284                  x-resourceId: d
1285                  properties:
1286                    value:
1287                      type: string
1288        "})
1289        .unwrap();
1290
1291        let spec = IrSpec::from_doc(&doc).unwrap();
1292        let ir_graph = IrGraph::new(&spec);
1293        let graph = CodegenGraph::new(ir_graph);
1294
1295        let op = graph.operations().find(|o| o.id() == "getThings").unwrap();
1296        let cfg = CfgFeature::for_operation(&op);
1297
1298        // A transitively implies B, C, and D; only `a` should remain.
1299        let actual: syn::Attribute = parse_quote!(#cfg);
1300        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1301        assert_eq!(actual, expected);
1302    }
1303
1304    #[test]
1305    fn test_for_operation_with_no_types() {
1306        // An operation with no parameters, request body, or response body.
1307        let doc = Document::from_yaml(indoc::indoc! {"
1308            openapi: 3.0.0
1309            info:
1310              title: Test
1311              version: 1.0.0
1312            paths:
1313              /health:
1314                get:
1315                  operationId: healthCheck
1316                  responses:
1317                    '200':
1318                      description: OK
1319        "})
1320        .unwrap();
1321
1322        let spec = IrSpec::from_doc(&doc).unwrap();
1323        let ir_graph = IrGraph::new(&spec);
1324        let graph = CodegenGraph::new(ir_graph);
1325
1326        let op = graph
1327            .operations()
1328            .find(|o| o.id() == "healthCheck")
1329            .unwrap();
1330        let cfg = CfgFeature::for_operation(&op);
1331
1332        // An operation with no type dependencies should have no feature gate.
1333        assert_eq!(cfg, None);
1334    }
1335
1336    #[test]
1337    fn test_for_inline_type_reduces_transitive_features() {
1338        // Inline type inside a response, with A -> B -> C chain.
1339        // Only `a` should remain after reduction.
1340        let doc = Document::from_yaml(indoc::indoc! {"
1341            openapi: 3.0.0
1342            info:
1343              title: Test
1344              version: 1.0.0
1345            paths:
1346              /things:
1347                get:
1348                  operationId: getThings
1349                  responses:
1350                    '200':
1351                      description: OK
1352                      content:
1353                        application/json:
1354                          schema:
1355                            type: object
1356                            properties:
1357                              a:
1358                                $ref: '#/components/schemas/A'
1359            components:
1360              schemas:
1361                A:
1362                  type: object
1363                  x-resourceId: a
1364                  properties:
1365                    b:
1366                      $ref: '#/components/schemas/B'
1367                B:
1368                  type: object
1369                  x-resourceId: b
1370                  properties:
1371                    c:
1372                      $ref: '#/components/schemas/C'
1373                C:
1374                  type: object
1375                  x-resourceId: c
1376                  properties:
1377                    value:
1378                      type: string
1379        "})
1380        .unwrap();
1381
1382        let spec = IrSpec::from_doc(&doc).unwrap();
1383        let ir_graph = IrGraph::new(&spec);
1384        let graph = CodegenGraph::new(ir_graph);
1385
1386        let ops = graph.operations().collect_vec();
1387        let inlines = ops.iter().flat_map(|op| op.inlines()).collect_vec();
1388        assert!(!inlines.is_empty());
1389
1390        let cfg = CfgFeature::for_inline_type(&inlines[0]);
1391
1392        let actual: syn::Attribute = parse_quote!(#cfg);
1393        let expected: syn::Attribute = parse_quote!(#[cfg(feature = "a")]);
1394        assert_eq!(actual, expected);
1395    }
1396}