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.into_schema().ok()?.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.into_schema().ok()?.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 arena::Arena,
146 ir::{RawGraph, Spec},
147 parse::Document,
148 };
149
150 use crate::tests::assert_matches;
151
152 fn default_manifest() -> Manifest<CargoMetadata> {
153 Manifest {
154 package: Some(Package::new("test-client", "0.1.0")),
155 ..Default::default()
156 }
157 }
158
159 #[test]
162 fn test_schema_with_x_resource_id_creates_feature() {
163 let doc = Document::from_yaml(indoc::indoc! {"
164 openapi: 3.0.0
165 info:
166 title: Test
167 version: 1.0.0
168 components:
169 schemas:
170 Customer:
171 type: object
172 x-resourceId: customer
173 properties:
174 id:
175 type: string
176 "})
177 .unwrap();
178
179 let arena = Arena::new();
180 let spec = Spec::from_doc(&arena, &doc).unwrap();
181 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
182 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
183
184 let keys = manifest
185 .features
186 .keys()
187 .map(|feature| feature.as_str())
188 .collect_vec();
189 assert_matches!(&*keys, ["customer", "default"]);
190 }
191
192 #[test]
193 fn test_operation_with_x_resource_name_creates_feature() {
194 let doc = Document::from_yaml(indoc::indoc! {"
195 openapi: 3.0.0
196 info:
197 title: Test
198 version: 1.0.0
199 paths:
200 /pets:
201 get:
202 operationId: listPets
203 x-resource-name: pets
204 responses:
205 '200':
206 description: OK
207 "})
208 .unwrap();
209
210 let arena = Arena::new();
211 let spec = Spec::from_doc(&arena, &doc).unwrap();
212 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
213 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
214
215 let keys = manifest
216 .features
217 .keys()
218 .map(|feature| feature.as_str())
219 .collect_vec();
220 assert_matches!(&*keys, ["default", "pets"]);
221 }
222
223 #[test]
224 fn test_unnamed_schema_creates_no_features() {
225 let doc = Document::from_yaml(indoc::indoc! {"
226 openapi: 3.0.0
227 info:
228 title: Test
229 version: 1.0.0
230 components:
231 schemas:
232 Simple:
233 type: object
234 properties:
235 id:
236 type: string
237 "})
238 .unwrap();
239
240 let arena = Arena::new();
241 let spec = Spec::from_doc(&arena, &doc).unwrap();
242 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
243 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
244
245 let keys = manifest
246 .features
247 .keys()
248 .map(|feature| feature.as_str())
249 .collect_vec();
250 assert_matches!(&*keys, []);
251 }
252
253 #[test]
256 fn test_schema_dependency_creates_feature_dependency() {
257 let doc = Document::from_yaml(indoc::indoc! {"
258 openapi: 3.0.0
259 info:
260 title: Test
261 version: 1.0.0
262 components:
263 schemas:
264 Customer:
265 type: object
266 x-resourceId: customer
267 properties:
268 billing:
269 $ref: '#/components/schemas/BillingInfo'
270 BillingInfo:
271 type: object
272 x-resourceId: billing
273 properties:
274 card:
275 type: string
276 "})
277 .unwrap();
278
279 let arena = Arena::new();
280 let spec = Spec::from_doc(&arena, &doc).unwrap();
281 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
282 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
283
284 let customer_deps = manifest.features["customer"]
287 .iter()
288 .map(|dep| dep.as_str())
289 .collect_vec();
290 assert_matches!(&*customer_deps, ["billing"]);
291 }
292
293 #[test]
294 fn test_transitive_schema_dependency_creates_feature_dependency() {
295 let doc = Document::from_yaml(indoc::indoc! {"
296 openapi: 3.0.0
297 info:
298 title: Test
299 version: 1.0.0
300 components:
301 schemas:
302 Order:
303 type: object
304 x-resourceId: orders
305 properties:
306 customer:
307 $ref: '#/components/schemas/Customer'
308 Customer:
309 type: object
310 x-resourceId: customer
311 properties:
312 billing:
313 $ref: '#/components/schemas/BillingInfo'
314 BillingInfo:
315 type: object
316 x-resourceId: billing
317 properties:
318 card:
319 type: string
320 "})
321 .unwrap();
322
323 let arena = Arena::new();
324 let spec = Spec::from_doc(&arena, &doc).unwrap();
325 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
326 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
327
328 let order_deps = manifest.features["orders"]
331 .iter()
332 .map(|dep| dep.as_str())
333 .collect_vec();
334 assert_matches!(&*order_deps, ["billing", "customer"]);
335 }
336
337 #[test]
338 fn test_unnamed_dependency_does_not_create_feature_dependency() {
339 let doc = Document::from_yaml(indoc::indoc! {"
340 openapi: 3.0.0
341 info:
342 title: Test
343 version: 1.0.0
344 components:
345 schemas:
346 Customer:
347 type: object
348 x-resourceId: customer
349 properties:
350 address:
351 $ref: '#/components/schemas/Address'
352 Address:
353 type: object
354 properties:
355 street:
356 type: string
357 "})
358 .unwrap();
359
360 let arena = Arena::new();
361 let spec = Spec::from_doc(&arena, &doc).unwrap();
362 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
363 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
364
365 let customer_deps = manifest.features["customer"]
369 .iter()
370 .map(|dep| dep.as_str())
371 .collect_vec();
372 assert_matches!(&*customer_deps, []);
373 }
374
375 #[test]
376 fn test_feature_does_not_depend_on_itself() {
377 let doc = Document::from_yaml(indoc::indoc! {"
378 openapi: 3.0.0
379 info:
380 title: Test
381 version: 1.0.0
382 components:
383 schemas:
384 Node:
385 type: object
386 x-resourceId: nodes
387 properties:
388 children:
389 type: array
390 items:
391 $ref: '#/components/schemas/Node'
392 "})
393 .unwrap();
394
395 let arena = Arena::new();
396 let spec = Spec::from_doc(&arena, &doc).unwrap();
397 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
398 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
399
400 let node_deps = manifest.features["nodes"]
402 .iter()
403 .map(|dep| dep.as_str())
404 .collect_vec();
405 assert_matches!(&*node_deps, []);
406 }
407
408 #[test]
411 fn test_operation_type_dependency_creates_feature_dependency() {
412 let doc = Document::from_yaml(indoc::indoc! {"
413 openapi: 3.0.0
414 info:
415 title: Test
416 version: 1.0.0
417 paths:
418 /orders:
419 get:
420 operationId: listOrders
421 x-resource-name: orders
422 responses:
423 '200':
424 description: OK
425 content:
426 application/json:
427 schema:
428 type: array
429 items:
430 $ref: '#/components/schemas/Order'
431 components:
432 schemas:
433 Order:
434 type: object
435 properties:
436 customer:
437 $ref: '#/components/schemas/Customer'
438 Customer:
439 type: object
440 x-resourceId: customer
441 properties:
442 id:
443 type: string
444 "})
445 .unwrap();
446
447 let arena = Arena::new();
448 let spec = Spec::from_doc(&arena, &doc).unwrap();
449 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
450 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
451
452 let orders_deps = manifest.features["orders"]
455 .iter()
456 .map(|dep| dep.as_str())
457 .collect_vec();
458 assert_matches!(&*orders_deps, ["customer"]);
459 }
460
461 #[test]
462 fn test_operation_with_unnamed_type_dependency_does_not_create_full_dependency() {
463 let doc = Document::from_yaml(indoc::indoc! {"
464 openapi: 3.0.0
465 info:
466 title: Test
467 version: 1.0.0
468 paths:
469 /customers:
470 get:
471 operationId: listCustomers
472 x-resource-name: customer
473 responses:
474 '200':
475 description: OK
476 content:
477 application/json:
478 schema:
479 type: array
480 items:
481 $ref: '#/components/schemas/Customer'
482 components:
483 schemas:
484 Customer:
485 type: object
486 properties:
487 address:
488 $ref: '#/components/schemas/Address'
489 Address:
490 type: object
491 properties:
492 street:
493 type: string
494 "})
495 .unwrap();
496
497 let arena = Arena::new();
498 let spec = Spec::from_doc(&arena, &doc).unwrap();
499 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
500 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
501
502 let customer_deps = manifest.features["customer"]
505 .iter()
506 .map(|dep| dep.as_str())
507 .collect_vec();
508 assert_matches!(&*customer_deps, []);
509 }
510
511 #[test]
514 fn test_diamond_dependency_deduplicates_feature() {
515 let doc = Document::from_yaml(indoc::indoc! {"
518 openapi: 3.0.0
519 info:
520 title: Test
521 version: 1.0.0
522 components:
523 schemas:
524 A:
525 type: object
526 x-resourceId: a
527 properties:
528 b:
529 $ref: '#/components/schemas/B'
530 c:
531 $ref: '#/components/schemas/C'
532 B:
533 type: object
534 x-resourceId: b
535 properties:
536 d:
537 $ref: '#/components/schemas/D'
538 C:
539 type: object
540 x-resourceId: c
541 properties:
542 d:
543 $ref: '#/components/schemas/D'
544 D:
545 type: object
546 x-resourceId: d
547 properties:
548 value:
549 type: string
550 "})
551 .unwrap();
552
553 let arena = Arena::new();
554 let spec = Spec::from_doc(&arena, &doc).unwrap();
555 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
556 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
557
558 let a_deps = manifest.features["a"]
560 .iter()
561 .map(|dep| dep.as_str())
562 .collect_vec();
563 assert_matches!(&*a_deps, ["b", "c", "d"]);
564
565 let b_deps = manifest.features["b"]
567 .iter()
568 .map(|dep| dep.as_str())
569 .collect_vec();
570 assert_matches!(&*b_deps, ["d"]);
571
572 let c_deps = manifest.features["c"]
573 .iter()
574 .map(|dep| dep.as_str())
575 .collect_vec();
576 assert_matches!(&*c_deps, ["d"]);
577
578 let d_deps = manifest.features["d"]
580 .iter()
581 .map(|dep| dep.as_str())
582 .collect_vec();
583 assert_matches!(&*d_deps, []);
584 }
585
586 #[test]
589 fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
590 let doc = Document::from_yaml(indoc::indoc! {"
594 openapi: 3.0.0
595 info:
596 title: Test
597 version: 1.0.0
598 components:
599 schemas:
600 A:
601 type: object
602 x-resourceId: a
603 properties:
604 b:
605 $ref: '#/components/schemas/B'
606 B:
607 type: object
608 properties:
609 c:
610 $ref: '#/components/schemas/C'
611 C:
612 type: object
613 x-resourceId: c
614 properties:
615 a:
616 $ref: '#/components/schemas/A'
617 "})
618 .unwrap();
619
620 let arena = Arena::new();
621 let spec = Spec::from_doc(&arena, &doc).unwrap();
622 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
623 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
624
625 let a_deps = manifest.features["a"]
627 .iter()
628 .map(|dep| dep.as_str())
629 .collect_vec();
630 assert_matches!(&*a_deps, ["c"]);
631
632 let c_deps = manifest.features["c"]
634 .iter()
635 .map(|dep| dep.as_str())
636 .collect_vec();
637 assert_matches!(&*c_deps, ["a"]);
638
639 let default_deps = manifest.features["default"]
641 .iter()
642 .map(|dep| dep.as_str())
643 .collect_vec();
644 assert_matches!(&*default_deps, ["a", "c"]);
645 }
646
647 #[test]
648 fn test_cycle_with_all_named_resources_creates_mutual_dependencies() {
649 let doc = Document::from_yaml(indoc::indoc! {"
652 openapi: 3.0.0
653 info:
654 title: Test
655 version: 1.0.0
656 components:
657 schemas:
658 A:
659 type: object
660 x-resourceId: a
661 properties:
662 b:
663 $ref: '#/components/schemas/B'
664 B:
665 type: object
666 x-resourceId: b
667 properties:
668 c:
669 $ref: '#/components/schemas/C'
670 C:
671 type: object
672 x-resourceId: c
673 properties:
674 a:
675 $ref: '#/components/schemas/A'
676 "})
677 .unwrap();
678
679 let arena = Arena::new();
680 let spec = Spec::from_doc(&arena, &doc).unwrap();
681 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
682 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
683
684 let a_deps = manifest.features["a"]
686 .iter()
687 .map(|dep| dep.as_str())
688 .collect_vec();
689 assert_matches!(&*a_deps, ["b", "c"]);
690
691 let b_deps = manifest.features["b"]
693 .iter()
694 .map(|dep| dep.as_str())
695 .collect_vec();
696 assert_matches!(&*b_deps, ["a", "c"]);
697
698 let c_deps = manifest.features["c"]
700 .iter()
701 .map(|dep| dep.as_str())
702 .collect_vec();
703 assert_matches!(&*c_deps, ["a", "b"]);
704
705 let default_deps = manifest.features["default"]
707 .iter()
708 .map(|dep| dep.as_str())
709 .collect_vec();
710 assert_matches!(&*default_deps, ["a", "b", "c"]);
711 }
712
713 #[test]
716 fn test_default_feature_includes_all_other_features() {
717 let doc = Document::from_yaml(indoc::indoc! {"
718 openapi: 3.0.0
719 info:
720 title: Test
721 version: 1.0.0
722 paths:
723 /pets:
724 get:
725 operationId: listPets
726 x-resource-name: pets
727 responses:
728 '200':
729 description: OK
730 components:
731 schemas:
732 Customer:
733 type: object
734 x-resourceId: customer
735 properties:
736 id:
737 type: string
738 Order:
739 type: object
740 x-resourceId: orders
741 properties:
742 id:
743 type: string
744 "})
745 .unwrap();
746
747 let arena = Arena::new();
748 let spec = Spec::from_doc(&arena, &doc).unwrap();
749 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
750 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
751
752 let default_deps = manifest.features["default"]
754 .iter()
755 .map(|dep| dep.as_str())
756 .collect_vec();
757 assert_matches!(&*default_deps, ["customer", "orders", "pets"]);
758 }
759
760 #[test]
761 fn test_default_feature_includes_all_named_features() {
762 let doc = Document::from_yaml(indoc::indoc! {"
763 openapi: 3.0.0
764 info:
765 title: Test
766 version: 1.0.0
767 components:
768 schemas:
769 Customer:
770 type: object
771 x-resourceId: customer
772 properties:
773 id:
774 type: string
775 "})
776 .unwrap();
777
778 let arena = Arena::new();
779 let spec = Spec::from_doc(&arena, &doc).unwrap();
780 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
781 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
782
783 let default_deps = manifest.features["default"]
785 .iter()
786 .map(|dep| dep.as_str())
787 .collect_vec();
788 assert_matches!(&*default_deps, ["customer"]);
789 }
790
791 #[test]
794 fn test_preserves_existing_dependencies() {
795 let doc = Document::from_yaml(indoc::indoc! {"
796 openapi: 3.0.0
797 info:
798 title: Test
799 version: 1.0.0
800 paths: {}
801 "})
802 .unwrap();
803
804 let mut manifest = default_manifest();
805 manifest
806 .dependencies
807 .insert("serde".to_owned(), Dependency::Simple("1.0".to_owned()));
808
809 let arena = Arena::new();
810 let spec = Spec::from_doc(&arena, &doc).unwrap();
811 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
812 let manifest = CodegenCargoManifest::new(&graph, &manifest).to_manifest();
813
814 let dep_names = manifest
815 .dependencies
816 .keys()
817 .map(|k| k.as_str())
818 .collect_vec();
819 assert_matches!(&*dep_names, ["ploidy-util", "serde"]);
820 }
821}