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 manifest
29 .package
30 .as_mut()
31 .unwrap()
32 .edition
33 .set(Edition::E2024);
34
35 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 let features = {
50 let mut deps_by_feature = BTreeMap::new();
51
52 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 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 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 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#[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 #[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 #[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 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 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 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 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 #[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 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 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 #[test]
513 fn test_diamond_dependency_deduplicates_feature() {
514 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 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 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 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 #[test]
588 fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
589 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 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 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 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 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 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 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 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 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 #[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 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 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 #[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}