Skip to main content

ploidy_codegen_rust/
cargo.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use cargo_toml::{Dependency, DependencyDetail, Edition, Manifest};
4use itertools::Itertools;
5use ploidy_core::{codegen::IntoCode, ir::View};
6use serde::{Deserialize, Serialize};
7
8use super::{config::CodegenConfig, graph::CodegenGraph, naming::CargoFeature};
9
10const PLOIDY_VERSION: &str = env!("CARGO_PKG_VERSION");
11
12#[derive(Clone, Debug)]
13pub struct CodegenCargoManifest<'a> {
14    graph: &'a CodegenGraph<'a>,
15    manifest: &'a Manifest<CargoMetadata>,
16}
17
18impl<'a> CodegenCargoManifest<'a> {
19    #[inline]
20    pub fn new(graph: &'a CodegenGraph<'a>, manifest: &'a Manifest<CargoMetadata>) -> Self {
21        Self { graph, manifest }
22    }
23
24    pub fn to_manifest(self) -> Manifest<CargoMetadata> {
25        let mut manifest = self.manifest.clone();
26
27        // Ploidy generates Rust 2024-compatible code.
28        manifest
29            .package
30            .as_mut()
31            .unwrap()
32            .edition
33            .set(Edition::E2024);
34
35        // `ploidy-util` is our only runtime dependency.
36        manifest.dependencies.insert(
37            "ploidy-util".to_owned(),
38            Dependency::Detailed(
39                DependencyDetail {
40                    version: Some(PLOIDY_VERSION.to_owned()),
41                    ..Default::default()
42                }
43                .into(),
44            ),
45        );
46
47        // Translate resource names from operations and schemas into
48        // Cargo feature names with dependencies.
49        let features = {
50            let mut deps_by_feature = BTreeMap::new();
51
52            // For each schema type with an explicitly declared resource name,
53            // use the resource name as the feature name, and enable features
54            // for all its transitive dependencies.
55            for schema in self.graph.schemas() {
56                let feature = match schema.resource().map(CargoFeature::from_name) {
57                    Some(CargoFeature::Named(name)) => CargoFeature::Named(name),
58                    _ => continue,
59                };
60                let entry: &mut BTreeSet<_> = deps_by_feature.entry(feature).or_default();
61                for dep in schema.dependencies().filter_map(|ty| {
62                    match CargoFeature::from_name(ty.as_schema()?.resource()?) {
63                        CargoFeature::Named(name) => Some(CargoFeature::Named(name)),
64                        CargoFeature::Default => None,
65                    }
66                }) {
67                    entry.insert(dep);
68                }
69            }
70
71            // For each operation with an explicitly declared resource name,
72            // use the resource name as the feature name, and enable features for
73            // all the types that are reachable from the operation.
74            for op in self.graph.operations() {
75                let feature = match op.resource().map(CargoFeature::from_name) {
76                    Some(CargoFeature::Named(name)) => CargoFeature::Named(name),
77                    _ => continue,
78                };
79                let entry = deps_by_feature.entry(feature).or_default();
80                for dep in op.dependencies().filter_map(|ty| {
81                    match CargoFeature::from_name(ty.as_schema()?.resource()?) {
82                        CargoFeature::Named(name) => Some(CargoFeature::Named(name)),
83                        CargoFeature::Default => None,
84                    }
85                }) {
86                    entry.insert(dep);
87                }
88            }
89
90            // Build the `features` section of the manifest.
91            let mut features: BTreeMap<_, _> = deps_by_feature
92                .iter()
93                .map(|(feature, deps)| {
94                    (
95                        feature.display().to_string(),
96                        deps.iter()
97                            .map(|dep| dep.display().to_string())
98                            .collect_vec(),
99                    )
100                })
101                .collect();
102            if features.is_empty() {
103                BTreeMap::new()
104            } else {
105                // `default` enables all other features.
106                features.insert(
107                    "default".to_owned(),
108                    deps_by_feature
109                        .keys()
110                        .map(|feature| feature.display().to_string())
111                        .collect_vec(),
112                );
113                features
114            }
115        };
116
117        Manifest {
118            features,
119            ..manifest
120        }
121    }
122}
123
124impl IntoCode for CodegenCargoManifest<'_> {
125    type Code = (&'static str, Manifest<CargoMetadata>);
126
127    fn into_code(self) -> Self::Code {
128        ("Cargo.toml", self.to_manifest())
129    }
130}
131
132/// Cargo metadata for the generated crate.
133#[derive(Clone, Debug, Default, Deserialize, Serialize)]
134pub struct CargoMetadata {
135    #[serde(default)]
136    pub ploidy: Option<CodegenConfig>,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    use cargo_toml::Package;
144    use ploidy_core::{
145        ir::{IrGraph, IrSpec},
146        parse::Document,
147    };
148
149    use crate::tests::assert_matches;
150
151    fn default_manifest() -> Manifest<CargoMetadata> {
152        Manifest {
153            package: Some(Package::new("test-client", "0.1.0")),
154            ..Default::default()
155        }
156    }
157
158    // MARK: Feature collection
159
160    #[test]
161    fn test_schema_with_x_resource_id_creates_feature() {
162        let doc = Document::from_yaml(indoc::indoc! {"
163            openapi: 3.0.0
164            info:
165              title: Test
166              version: 1.0.0
167            components:
168              schemas:
169                Customer:
170                  type: object
171                  x-resourceId: customer
172                  properties:
173                    id:
174                      type: string
175        "})
176        .unwrap();
177
178        let spec = IrSpec::from_doc(&doc).unwrap();
179        let ir_graph = IrGraph::new(&spec);
180        let graph = CodegenGraph::new(ir_graph);
181        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
182
183        let keys = manifest
184            .features
185            .keys()
186            .map(|feature| feature.as_str())
187            .collect_vec();
188        assert_matches!(&*keys, ["customer", "default"]);
189    }
190
191    #[test]
192    fn test_operation_with_x_resource_name_creates_feature() {
193        let doc = Document::from_yaml(indoc::indoc! {"
194            openapi: 3.0.0
195            info:
196              title: Test
197              version: 1.0.0
198            paths:
199              /pets:
200                get:
201                  operationId: listPets
202                  x-resource-name: pets
203                  responses:
204                    '200':
205                      description: OK
206        "})
207        .unwrap();
208
209        let spec = IrSpec::from_doc(&doc).unwrap();
210        let ir_graph = IrGraph::new(&spec);
211        let graph = CodegenGraph::new(ir_graph);
212        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
213
214        let keys = manifest
215            .features
216            .keys()
217            .map(|feature| feature.as_str())
218            .collect_vec();
219        assert_matches!(&*keys, ["default", "pets"]);
220    }
221
222    #[test]
223    fn test_unnamed_schema_creates_no_features() {
224        let doc = Document::from_yaml(indoc::indoc! {"
225            openapi: 3.0.0
226            info:
227              title: Test
228              version: 1.0.0
229            components:
230              schemas:
231                Simple:
232                  type: object
233                  properties:
234                    id:
235                      type: string
236        "})
237        .unwrap();
238
239        let spec = IrSpec::from_doc(&doc).unwrap();
240        let ir_graph = IrGraph::new(&spec);
241        let graph = CodegenGraph::new(ir_graph);
242        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
243
244        let keys = manifest
245            .features
246            .keys()
247            .map(|feature| feature.as_str())
248            .collect_vec();
249        assert_matches!(&*keys, []);
250    }
251
252    // MARK: Schema feature dependencies
253
254    #[test]
255    fn test_schema_dependency_creates_feature_dependency() {
256        let doc = Document::from_yaml(indoc::indoc! {"
257            openapi: 3.0.0
258            info:
259              title: Test
260              version: 1.0.0
261            components:
262              schemas:
263                Customer:
264                  type: object
265                  x-resourceId: customer
266                  properties:
267                    billing:
268                      $ref: '#/components/schemas/BillingInfo'
269                BillingInfo:
270                  type: object
271                  x-resourceId: billing
272                  properties:
273                    card:
274                      type: string
275        "})
276        .unwrap();
277
278        let spec = IrSpec::from_doc(&doc).unwrap();
279        let ir_graph = IrGraph::new(&spec);
280        let graph = CodegenGraph::new(ir_graph);
281        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
282
283        // `Customer` depends on `BillingInfo`, so the `customer` feature
284        // should depend on `billing`.
285        let customer_deps = manifest.features["customer"]
286            .iter()
287            .map(|dep| dep.as_str())
288            .collect_vec();
289        assert_matches!(&*customer_deps, ["billing"]);
290    }
291
292    #[test]
293    fn test_transitive_schema_dependency_creates_feature_dependency() {
294        let doc = Document::from_yaml(indoc::indoc! {"
295            openapi: 3.0.0
296            info:
297              title: Test
298              version: 1.0.0
299            components:
300              schemas:
301                Order:
302                  type: object
303                  x-resourceId: orders
304                  properties:
305                    customer:
306                      $ref: '#/components/schemas/Customer'
307                Customer:
308                  type: object
309                  x-resourceId: customer
310                  properties:
311                    billing:
312                      $ref: '#/components/schemas/BillingInfo'
313                BillingInfo:
314                  type: object
315                  x-resourceId: billing
316                  properties:
317                    card:
318                      type: string
319        "})
320        .unwrap();
321
322        let spec = IrSpec::from_doc(&doc).unwrap();
323        let ir_graph = IrGraph::new(&spec);
324        let graph = CodegenGraph::new(ir_graph);
325        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
326
327        // `Order` → `Customer` → `BillingInfo`, so `order` should
328        // depend on both `customer` and `billing`.
329        let order_deps = manifest.features["orders"]
330            .iter()
331            .map(|dep| dep.as_str())
332            .collect_vec();
333        assert_matches!(&*order_deps, ["billing", "customer"]);
334    }
335
336    #[test]
337    fn test_unnamed_dependency_does_not_create_feature_dependency() {
338        let doc = Document::from_yaml(indoc::indoc! {"
339            openapi: 3.0.0
340            info:
341              title: Test
342              version: 1.0.0
343            components:
344              schemas:
345                Customer:
346                  type: object
347                  x-resourceId: customer
348                  properties:
349                    address:
350                      $ref: '#/components/schemas/Address'
351                Address:
352                  type: object
353                  properties:
354                    street:
355                      type: string
356        "})
357        .unwrap();
358
359        let spec = IrSpec::from_doc(&doc).unwrap();
360        let ir_graph = IrGraph::new(&spec);
361        let graph = CodegenGraph::new(ir_graph);
362        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
363
364        // `Customer` depends on `Address`, which doesn't have a resource.
365        // The `customer` feature should _not_ depend on `default`;
366        // that's handled via `cfg` attributes instead.
367        let customer_deps = manifest.features["customer"]
368            .iter()
369            .map(|dep| dep.as_str())
370            .collect_vec();
371        assert_matches!(&*customer_deps, []);
372    }
373
374    #[test]
375    fn test_feature_does_not_depend_on_itself() {
376        let doc = Document::from_yaml(indoc::indoc! {"
377            openapi: 3.0.0
378            info:
379              title: Test
380              version: 1.0.0
381            components:
382              schemas:
383                Node:
384                  type: object
385                  x-resourceId: nodes
386                  properties:
387                    children:
388                      type: array
389                      items:
390                        $ref: '#/components/schemas/Node'
391        "})
392        .unwrap();
393
394        let spec = IrSpec::from_doc(&doc).unwrap();
395        let ir_graph = IrGraph::new(&spec);
396        let graph = CodegenGraph::new(ir_graph);
397        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
398
399        // Self-referential schemas should not create self-dependencies.
400        let node_deps = manifest.features["nodes"]
401            .iter()
402            .map(|dep| dep.as_str())
403            .collect_vec();
404        assert_matches!(&*node_deps, []);
405    }
406
407    // MARK: Operation feature dependencies
408
409    #[test]
410    fn test_operation_type_dependency_creates_feature_dependency() {
411        let doc = Document::from_yaml(indoc::indoc! {"
412            openapi: 3.0.0
413            info:
414              title: Test
415              version: 1.0.0
416            paths:
417              /orders:
418                get:
419                  operationId: listOrders
420                  x-resource-name: orders
421                  responses:
422                    '200':
423                      description: OK
424                      content:
425                        application/json:
426                          schema:
427                            type: array
428                            items:
429                              $ref: '#/components/schemas/Order'
430            components:
431              schemas:
432                Order:
433                  type: object
434                  properties:
435                    customer:
436                      $ref: '#/components/schemas/Customer'
437                Customer:
438                  type: object
439                  x-resourceId: customer
440                  properties:
441                    id:
442                      type: string
443        "})
444        .unwrap();
445
446        let spec = IrSpec::from_doc(&doc).unwrap();
447        let ir_graph = IrGraph::new(&spec);
448        let graph = CodegenGraph::new(ir_graph);
449        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
450
451        // `listOrders` returns `Order`, which references `Customer`, so
452        // `orders` should depend on `customer`.
453        let orders_deps = manifest.features["orders"]
454            .iter()
455            .map(|dep| dep.as_str())
456            .collect_vec();
457        assert_matches!(&*orders_deps, ["customer"]);
458    }
459
460    #[test]
461    fn test_operation_with_unnamed_type_dependency_does_not_create_full_dependency() {
462        let doc = Document::from_yaml(indoc::indoc! {"
463            openapi: 3.0.0
464            info:
465              title: Test
466              version: 1.0.0
467            paths:
468              /customers:
469                get:
470                  operationId: listCustomers
471                  x-resource-name: customer
472                  responses:
473                    '200':
474                      description: OK
475                      content:
476                        application/json:
477                          schema:
478                            type: array
479                            items:
480                              $ref: '#/components/schemas/Customer'
481            components:
482              schemas:
483                Customer:
484                  type: object
485                  properties:
486                    address:
487                      $ref: '#/components/schemas/Address'
488                Address:
489                  type: object
490                  properties:
491                    street:
492                      type: string
493        "})
494        .unwrap();
495
496        let spec = IrSpec::from_doc(&doc).unwrap();
497        let ir_graph = IrGraph::new(&spec);
498        let graph = CodegenGraph::new(ir_graph);
499        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
500
501        // `listOrders` returns `Customer`, which references `Address`, but
502        // `customer` should _not_ depend on `default`.
503        let customer_deps = manifest.features["customer"]
504            .iter()
505            .map(|dep| dep.as_str())
506            .collect_vec();
507        assert_matches!(&*customer_deps, []);
508    }
509
510    // MARK: Diamond dependencies
511
512    #[test]
513    fn test_diamond_dependency_deduplicates_feature() {
514        // A -> B, A -> C, B -> D, C -> D. All have resources.
515        // A's feature should depend on B, C, and D; D should appear once.
516        let doc = Document::from_yaml(indoc::indoc! {"
517            openapi: 3.0.0
518            info:
519              title: Test
520              version: 1.0.0
521            components:
522              schemas:
523                A:
524                  type: object
525                  x-resourceId: a
526                  properties:
527                    b:
528                      $ref: '#/components/schemas/B'
529                    c:
530                      $ref: '#/components/schemas/C'
531                B:
532                  type: object
533                  x-resourceId: b
534                  properties:
535                    d:
536                      $ref: '#/components/schemas/D'
537                C:
538                  type: object
539                  x-resourceId: c
540                  properties:
541                    d:
542                      $ref: '#/components/schemas/D'
543                D:
544                  type: object
545                  x-resourceId: d
546                  properties:
547                    value:
548                      type: string
549        "})
550        .unwrap();
551
552        let spec = IrSpec::from_doc(&doc).unwrap();
553        let ir_graph = IrGraph::new(&spec);
554        let graph = CodegenGraph::new(ir_graph);
555        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
556
557        // `a` depends directly on `b`, `c`; transitively on `d` though `b` and `c`.
558        let a_deps = manifest.features["a"]
559            .iter()
560            .map(|dep| dep.as_str())
561            .collect_vec();
562        assert_matches!(&*a_deps, ["b", "c", "d"]);
563
564        // `b` and `c` each depend on `d`.
565        let b_deps = manifest.features["b"]
566            .iter()
567            .map(|dep| dep.as_str())
568            .collect_vec();
569        assert_matches!(&*b_deps, ["d"]);
570
571        let c_deps = manifest.features["c"]
572            .iter()
573            .map(|dep| dep.as_str())
574            .collect_vec();
575        assert_matches!(&*c_deps, ["d"]);
576
577        // `d` has no dependencies.
578        let d_deps = manifest.features["d"]
579            .iter()
580            .map(|dep| dep.as_str())
581            .collect_vec();
582        assert_matches!(&*d_deps, []);
583    }
584
585    // MARK: Cycles with mixed resources
586
587    #[test]
588    fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
589        // Type A (resource `a`) -> Type B (no resource) -> Type C (resource `c`) -> Type A.
590        // Since B doesn't have a resource, we don't create a dependency on it;
591        // that's handled via `#[cfg(...)]` attributes.
592        let doc = Document::from_yaml(indoc::indoc! {"
593            openapi: 3.0.0
594            info:
595              title: Test
596              version: 1.0.0
597            components:
598              schemas:
599                A:
600                  type: object
601                  x-resourceId: a
602                  properties:
603                    b:
604                      $ref: '#/components/schemas/B'
605                B:
606                  type: object
607                  properties:
608                    c:
609                      $ref: '#/components/schemas/C'
610                C:
611                  type: object
612                  x-resourceId: c
613                  properties:
614                    a:
615                      $ref: '#/components/schemas/A'
616        "})
617        .unwrap();
618
619        let spec = IrSpec::from_doc(&doc).unwrap();
620        let ir_graph = IrGraph::new(&spec);
621        let graph = CodegenGraph::new(ir_graph);
622        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
623
624        // A depends on B (unnamed) and C. Since B is unnamed, A only depends on C.
625        let a_deps = manifest.features["a"]
626            .iter()
627            .map(|dep| dep.as_str())
628            .collect_vec();
629        assert_matches!(&*a_deps, ["c"]);
630
631        // C depends on A (which depends on B, unnamed). C only depends on A.
632        let c_deps = manifest.features["c"]
633            .iter()
634            .map(|dep| dep.as_str())
635            .collect_vec();
636        assert_matches!(&*c_deps, ["a"]);
637
638        // `default` should include both named features.
639        let default_deps = manifest.features["default"]
640            .iter()
641            .map(|dep| dep.as_str())
642            .collect_vec();
643        assert_matches!(&*default_deps, ["a", "c"]);
644    }
645
646    #[test]
647    fn test_cycle_with_all_named_resources_creates_mutual_dependencies() {
648        // Type A (resource `a`) -> Type B (resource `b`) -> Type C (resource `c`) -> Type A.
649        // Each feature should depend on the others in the cycle.
650        let doc = Document::from_yaml(indoc::indoc! {"
651            openapi: 3.0.0
652            info:
653              title: Test
654              version: 1.0.0
655            components:
656              schemas:
657                A:
658                  type: object
659                  x-resourceId: a
660                  properties:
661                    b:
662                      $ref: '#/components/schemas/B'
663                B:
664                  type: object
665                  x-resourceId: b
666                  properties:
667                    c:
668                      $ref: '#/components/schemas/C'
669                C:
670                  type: object
671                  x-resourceId: c
672                  properties:
673                    a:
674                      $ref: '#/components/schemas/A'
675        "})
676        .unwrap();
677
678        let spec = IrSpec::from_doc(&doc).unwrap();
679        let ir_graph = IrGraph::new(&spec);
680        let graph = CodegenGraph::new(ir_graph);
681        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
682
683        // A transitively depends on B and C.
684        let a_deps = manifest.features["a"]
685            .iter()
686            .map(|dep| dep.as_str())
687            .collect_vec();
688        assert_matches!(&*a_deps, ["b", "c"]);
689
690        // B transitively depends on A and C.
691        let b_deps = manifest.features["b"]
692            .iter()
693            .map(|dep| dep.as_str())
694            .collect_vec();
695        assert_matches!(&*b_deps, ["a", "c"]);
696
697        // C transitively depends on A and B.
698        let c_deps = manifest.features["c"]
699            .iter()
700            .map(|dep| dep.as_str())
701            .collect_vec();
702        assert_matches!(&*c_deps, ["a", "b"]);
703
704        // `default` should include all three.
705        let default_deps = manifest.features["default"]
706            .iter()
707            .map(|dep| dep.as_str())
708            .collect_vec();
709        assert_matches!(&*default_deps, ["a", "b", "c"]);
710    }
711
712    // MARK: Default feature
713
714    #[test]
715    fn test_default_feature_includes_all_other_features() {
716        let doc = Document::from_yaml(indoc::indoc! {"
717            openapi: 3.0.0
718            info:
719              title: Test
720              version: 1.0.0
721            paths:
722              /pets:
723                get:
724                  operationId: listPets
725                  x-resource-name: pets
726                  responses:
727                    '200':
728                      description: OK
729            components:
730              schemas:
731                Customer:
732                  type: object
733                  x-resourceId: customer
734                  properties:
735                    id:
736                      type: string
737                Order:
738                  type: object
739                  x-resourceId: orders
740                  properties:
741                    id:
742                      type: string
743        "})
744        .unwrap();
745
746        let spec = IrSpec::from_doc(&doc).unwrap();
747        let ir_graph = IrGraph::new(&spec);
748        let graph = CodegenGraph::new(ir_graph);
749        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
750
751        // The `default` feature should include all other features, but not itself.
752        let default_deps = manifest.features["default"]
753            .iter()
754            .map(|dep| dep.as_str())
755            .collect_vec();
756        assert_matches!(&*default_deps, ["customer", "orders", "pets"]);
757    }
758
759    #[test]
760    fn test_default_feature_includes_all_named_features() {
761        let doc = Document::from_yaml(indoc::indoc! {"
762            openapi: 3.0.0
763            info:
764              title: Test
765              version: 1.0.0
766            components:
767              schemas:
768                Customer:
769                  type: object
770                  x-resourceId: customer
771                  properties:
772                    id:
773                      type: string
774        "})
775        .unwrap();
776
777        let spec = IrSpec::from_doc(&doc).unwrap();
778        let ir_graph = IrGraph::new(&spec);
779        let graph = CodegenGraph::new(ir_graph);
780        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
781
782        // The `default` feature should include all named features.
783        let default_deps = manifest.features["default"]
784            .iter()
785            .map(|dep| dep.as_str())
786            .collect_vec();
787        assert_matches!(&*default_deps, ["customer"]);
788    }
789
790    // MARK: Dependencies
791
792    #[test]
793    fn test_preserves_existing_dependencies() {
794        let doc = Document::from_yaml(indoc::indoc! {"
795            openapi: 3.0.0
796            info:
797              title: Test
798              version: 1.0.0
799            paths: {}
800        "})
801        .unwrap();
802
803        let mut manifest = default_manifest();
804        manifest
805            .dependencies
806            .insert("serde".to_owned(), Dependency::Simple("1.0".to_owned()));
807
808        let spec = IrSpec::from_doc(&doc).unwrap();
809        let ir_graph = IrGraph::new(&spec);
810        let graph = CodegenGraph::new(ir_graph);
811        let manifest = CodegenCargoManifest::new(&graph, &manifest).to_manifest();
812
813        let dep_names = manifest
814            .dependencies
815            .keys()
816            .map(|k| k.as_str())
817            .collect_vec();
818        assert_matches!(&*dep_names, ["ploidy-util", "serde"]);
819    }
820}