1use std::{
2 collections::{BTreeMap, BTreeSet},
3 error::Error as StdError,
4 fmt::{Debug, Display},
5 ops::Range,
6 path::Path,
7};
8
9use itertools::Itertools;
10use miette::SourceSpan;
11use ploidy_core::{codegen::Code, ir::View};
12use semver::Version;
13use serde::{Deserialize, de::IntoDeserializer};
14use toml_edit::{Array, DocumentMut, InlineTable, Table, TableLike, value};
15
16use super::{config::CodegenConfig, graph::CodegenGraph, naming::AsFeatureName};
17
18const PLOIDY_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20#[derive(Clone, Debug)]
21pub struct CodegenCargoManifest<'a> {
22 graph: &'a CodegenGraph<'a>,
23 manifest: &'a CargoManifest,
24}
25
26impl<'a> CodegenCargoManifest<'a> {
27 #[inline]
28 pub fn new(graph: &'a CodegenGraph<'a>, manifest: &'a CargoManifest) -> Self {
29 Self { graph, manifest }
30 }
31
32 pub fn to_manifest(self) -> CargoManifest {
33 let features = {
36 let mut deps_by_resource = BTreeMap::new();
37
38 for schema in self.graph.schemas() {
42 let Some(resource) = self.graph.resource_for(&schema).name() else {
43 continue;
44 };
45 let entry: &mut BTreeSet<_> = deps_by_resource.entry(resource).or_default();
46 entry.extend(
47 schema
48 .dependencies()
49 .filter_map(|ty| ty.into_schema().right())
50 .filter_map(|schema| self.graph.resource_for(&schema).name())
51 .filter(|dep| *dep != resource),
52 );
53 }
54
55 for op in self.graph.operations() {
59 let Some(resource) = self.graph.resource_for(&op).name() else {
60 continue;
61 };
62 let entry = deps_by_resource.entry(resource).or_default();
63 entry.extend(
64 op.dependencies()
65 .filter_map(|ty| ty.into_schema().right())
66 .filter_map(|schema| self.graph.resource_for(&schema).name())
67 .filter(|dep| *dep != resource),
68 );
69 }
70
71 let mut features: BTreeMap<_, _> = deps_by_resource
73 .iter()
74 .map(|(resource, deps)| {
75 (
76 AsFeatureName(*resource).to_string(),
77 FeatureDependencies(
78 deps.iter()
79 .map(|resource| AsFeatureName(*resource).to_string())
80 .collect_vec(),
81 ),
82 )
83 })
84 .collect();
85 if features.is_empty() {
86 BTreeMap::new()
87 } else {
88 features.insert(
90 "default".to_owned(),
91 FeatureDependencies(
92 deps_by_resource
93 .keys()
94 .map(|resource| AsFeatureName(*resource).to_string())
95 .collect_vec(),
96 ),
97 );
98 features
99 }
100 };
101
102 self.manifest.clone().apply(CargoManifestDiff {
103 edition: Some(RustEdition::E2024),
105 dependencies: Some(BTreeMap::from_iter([
106 (
108 "ploidy-util".to_owned(),
109 Dependency::Simple(PLOIDY_VERSION.parse().unwrap()),
110 ),
111 ])),
112 features: Some(features),
113 ..Default::default()
114 })
115 }
116}
117
118impl Code for CodegenCargoManifest<'_> {
119 fn path(&self) -> &str {
120 "Cargo.toml"
121 }
122
123 fn into_string(self) -> miette::Result<String> {
124 Ok(self.to_manifest().to_string())
125 }
126}
127
128#[derive(Clone, Debug)]
130pub struct CargoManifest(DocumentMut);
131
132impl CargoManifest {
133 pub fn new(name: &str, version: Version) -> Self {
135 let package = Table::from_iter([
136 ("name", value(name)),
137 ("version", value(version.to_string())),
138 ("edition", value(RustEdition::E2024)),
139 ]);
140 let manifest = Table::from_iter([("package", package)]);
141 Self(manifest.into())
142 }
143
144 pub fn from_disk(path: &Path) -> Result<Self, CargoManifestError> {
146 let contents = std::fs::read_to_string(path)?;
147 Self::parse(&contents)
148 }
149
150 pub fn parse(s: &str) -> Result<Self, CargoManifestError> {
152 Ok(Self(s.parse().map_err(
153 |source: toml_edit::TomlError| {
154 let span = source.span().map(SourceSpan::from);
155 SpannedError {
156 source: Box::new(source),
157 code: s.to_owned(),
158 span,
159 }
160 },
161 )?))
162 }
163
164 #[inline]
167 pub fn package(&self) -> Option<Package<'_>> {
168 let package = self.0.get("package")?.as_table_like()?;
169 let name = package.get("name")?;
170 let version = package.get("version")?;
171 Some(Package {
172 name: SpannedValue::new(name.as_str()?, &self.0, name.span()),
173 version: SpannedValue::new(version.as_str()?, &self.0, version.span()),
174 metadata: package
175 .get("metadata")
176 .and_then(|meta| Some((meta.as_table_like()?, meta.span())))
177 .map(|(meta, range)| SpannedValue::new(meta, &self.0, range)),
178 })
179 }
180
181 pub fn features(&self) -> BTreeMap<&str, Vec<&str>> {
183 self.0
184 .get("features")
185 .and_then(|features| features.as_table_like())
186 .into_iter()
187 .flat_map(|features| features.iter())
188 .map(|(name, item)| {
189 let deps = item
190 .as_array()
191 .into_iter()
192 .flat_map(|deps| deps.iter())
193 .filter_map(|dep| dep.as_str())
194 .collect_vec();
195 (name, deps)
196 })
197 .collect()
198 }
199
200 pub fn apply(mut self, diff: CargoManifestDiff) -> Self {
202 let package = &mut self.0["package"];
203 if let Some(name) = diff.name {
204 package["name"] = value(name);
205 }
206 if let Some(version) = diff.version {
207 package["version"] = value(version.to_string());
208 }
209 if let Some(edition) = diff.edition {
210 package["edition"] = value(edition);
211 }
212 if let Some(deps) = diff.dependencies.filter(|f| !f.is_empty()) {
213 let table = self.0["dependencies"].or_insert(Table::new().into());
214 for (name, dep) in deps {
215 dep.merge_into(&mut table[&name]);
216 }
217 }
218 if let Some(features) = diff.features.filter(|f| !f.is_empty()) {
219 let table = self.0["features"].or_insert(Table::new().into());
220 for (name, feature) in features {
221 feature.merge_into(&mut table[&name]);
222 }
223 }
224 self
225 }
226}
227
228impl Display for CargoManifest {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 write!(f, "{}", self.0)
231 }
232}
233
234#[derive(Clone, Copy)]
236pub struct Package<'a> {
237 name: SpannedValue<'a, &'a str>,
238 version: SpannedValue<'a, &'a str>,
239 metadata: Option<SpannedValue<'a, &'a dyn TableLike>>,
240}
241
242impl<'a> Package<'a> {
243 pub fn name(&self) -> &'a str {
245 self.name.value
246 }
247
248 pub fn version(&self) -> Result<Version, SpannedError<PackageError>> {
250 Version::parse(self.version.value).map_err(|err| SpannedError {
251 source: Box::new(PackageError::from(err)),
252 code: self.version.source.to_string(),
253 span: self.version.span,
254 })
255 }
256
257 pub fn config(&self) -> Result<Option<CodegenConfig>, SpannedError<PackageError>> {
261 let meta = match self.metadata {
262 Some(meta) => meta,
263 None => return Ok(None),
264 };
265 let table: Table = match meta.value.get("ploidy").and_then(|v| v.as_table_like()) {
266 Some(table) => table.iter().collect(),
267 None => return Ok(None),
268 };
269 let value: toml_edit::Value = table.into_inline_table().into();
270 let config =
271 CodegenConfig::deserialize(value.into_deserializer()).map_err(|err| SpannedError {
272 source: Box::new(PackageError::from(err)),
273 code: meta.source.to_string(),
274 span: meta.span,
275 })?;
276 Ok(Some(config))
277 }
278}
279
280impl Debug for Package<'_> {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 f.debug_struct("Package")
283 .field("name", &self.name)
284 .field("version", &self.version)
285 .finish_non_exhaustive()
286 }
287}
288
289#[derive(Clone, Copy, Debug)]
291struct SpannedValue<'a, T> {
292 source: &'a DocumentMut,
293 value: T,
294 span: Option<SourceSpan>,
295}
296
297impl<'a, T> SpannedValue<'a, T> {
298 fn new(value: T, source: &'a DocumentMut, range: Option<Range<usize>>) -> Self {
299 Self {
300 source,
301 value,
302 span: range.map(SourceSpan::from),
303 }
304 }
305}
306
307#[derive(Debug, miette::Diagnostic)]
309pub struct SpannedError<E: StdError + Send + Sync + 'static> {
310 source: Box<E>,
311 #[source_code]
312 code: String,
313 #[label]
314 span: Option<SourceSpan>,
315}
316
317impl<E: StdError + Send + Sync + 'static> Display for SpannedError<E> {
318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 Display::fmt(&self.source, f)
320 }
321}
322
323impl<E: StdError + Send + Sync + 'static> StdError for SpannedError<E> {
324 fn source(&self) -> Option<&(dyn StdError + 'static)> {
325 self.source.source()
328 }
329}
330
331#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
333pub enum RustEdition {
334 E2021,
335 #[default]
336 E2024,
337}
338
339impl From<RustEdition> for toml_edit::Value {
340 fn from(edition: RustEdition) -> Self {
341 toml_edit::Value::from(match edition {
342 RustEdition::E2021 => "2021",
343 RustEdition::E2024 => "2024",
344 })
345 }
346}
347
348#[derive(Clone, Debug, Default)]
350pub struct CargoManifestDiff {
351 pub name: Option<String>,
352 pub version: Option<Version>,
353 pub edition: Option<RustEdition>,
354 pub dependencies: Option<BTreeMap<String, Dependency>>,
355 pub features: Option<BTreeMap<String, FeatureDependencies>>,
356}
357
358#[derive(Clone, Debug)]
360pub enum Dependency {
361 Simple(Version),
362 Detailed(DependencyDetail),
363}
364
365impl Dependency {
366 fn merge_into(self, entry: &mut toml_edit::Item) {
370 match self {
371 Dependency::Simple(version) => {
372 if let Some(table) = entry.as_table_like_mut() {
373 table.insert("version", value(version.to_string()));
374 } else {
375 *entry = value(version.to_string());
376 }
377 }
378 Dependency::Detailed(detail) => {
379 let table = match entry.as_table_like_mut() {
380 Some(table) => table,
381 None => {
382 *entry = InlineTable::new().into();
383 entry.as_table_like_mut().unwrap()
384 }
385 };
386 table.insert("version", value(detail.version.to_string()));
387 if let Some(path) = detail.path {
388 table.insert("path", value(path));
389 }
390 }
391 }
392 }
393}
394
395#[derive(Clone, Debug)]
396pub struct DependencyDetail {
397 pub version: Version,
398 pub path: Option<String>,
399}
400
401#[derive(Clone, Debug)]
403pub struct FeatureDependencies(Vec<String>);
404
405impl FeatureDependencies {
406 fn merge_into(self, entry: &mut toml_edit::Item) {
411 match entry.as_array_mut() {
412 Some(array) => {
413 let existing: BTreeSet<_> = array.iter().filter_map(|dep| dep.as_str()).collect();
414 let new = self
415 .0
416 .into_iter()
417 .filter(|dep| !existing.contains(dep.as_str()))
418 .collect_vec();
419 array.extend(new);
420 }
421 None => {
422 *entry = Array::from_iter(self.0).into();
423 }
424 }
425 }
426}
427
428#[derive(Debug, thiserror::Error)]
429pub enum CargoManifestError {
430 #[error(transparent)]
431 Io(#[from] std::io::Error),
432
433 #[error(transparent)]
434 Parse(#[from] SpannedError<toml_edit::TomlError>),
435}
436
437#[derive(Debug, thiserror::Error)]
438pub enum PackageError {
439 #[error(transparent)]
440 Deserialize(#[from] toml_edit::de::Error),
441
442 #[error(transparent)]
443 Semver(#[from] semver::Error),
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 use ploidy_core::{
451 arena::Arena,
452 ir::{RawGraph, Spec},
453 parse::Document,
454 };
455
456 use crate::{config::DateTimeFormat, tests::assert_matches};
457
458 fn default_manifest() -> CargoManifest {
459 CargoManifest::new("test-client", Version::new(0, 1, 0))
460 }
461
462 #[test]
465 fn test_new_manifest_has_package_name_version_and_edition() {
466 assert_eq!(
467 CargoManifest::new("my-crate", Version::new(1, 0, 0)).to_string(),
468 indoc::indoc! {r#"
469 [package]
470 name = "my-crate"
471 version = "1.0.0"
472 edition = "2024"
473 "#},
474 );
475 }
476
477 #[test]
478 fn test_package_returns_none_for_workspace() {
479 let manifest = CargoManifest::parse(indoc::indoc! {r#"
480 [workspace]
481 members = ["a"]
482 "#})
483 .unwrap();
484 assert!(manifest.package().is_none());
485 }
486
487 #[test]
488 fn test_apply_sets_name() {
489 let manifest = CargoManifest::new("old", Version::new(1, 0, 0)).apply(CargoManifestDiff {
490 name: Some("new".to_owned()),
491 ..Default::default()
492 });
493 assert_eq!(manifest.package().unwrap().name.value, "new");
494 }
495
496 #[test]
497 fn test_apply_sets_version() {
498 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
499 version: Some(Version::new(2, 0, 0)),
500 ..Default::default()
501 });
502 assert_eq!(manifest.package().unwrap().version.value, "2.0.0");
503 }
504
505 #[test]
506 fn test_apply_sets_edition() {
507 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
508 edition: Some(RustEdition::E2021),
509 ..Default::default()
510 });
511 assert_eq!(
512 manifest.to_string(),
513 indoc::indoc! {r#"
514 [package]
515 name = "pkg"
516 version = "1.0.0"
517 edition = "2021"
518 "#},
519 );
520 }
521
522 #[test]
523 fn test_apply_sets_simple_dependency() {
524 let mut deps = BTreeMap::new();
525 deps.insert(
526 "serde".to_owned(),
527 Dependency::Simple(Version::new(1, 0, 0)),
528 );
529 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
530 dependencies: Some(deps),
531 ..Default::default()
532 });
533 assert_eq!(
534 manifest.to_string(),
535 indoc::indoc! {r#"
536 [package]
537 name = "pkg"
538 version = "1.0.0"
539 edition = "2024"
540
541 [dependencies]
542 serde = "1.0.0"
543 "#},
544 );
545 }
546
547 #[test]
548 fn test_apply_sets_detailed_dependency() {
549 let mut deps = BTreeMap::new();
550 deps.insert(
551 "ploidy-util".to_owned(),
552 Dependency::Detailed(DependencyDetail {
553 version: Version::new(0, 10, 0),
554 path: Some("../ploidy-util".to_owned()),
555 }),
556 );
557 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
558 dependencies: Some(deps),
559 ..Default::default()
560 });
561 assert_eq!(
562 manifest.to_string(),
563 indoc::indoc! {r#"
564 [package]
565 name = "pkg"
566 version = "1.0.0"
567 edition = "2024"
568
569 [dependencies]
570 ploidy-util = { version = "0.10.0", path = "../ploidy-util" }
571 "#},
572 );
573 }
574
575 #[test]
576 fn test_apply_preserves_existing_dependencies() {
577 let doc = Document::from_yaml(indoc::indoc! {"
578 openapi: 3.0.0
579 info:
580 title: Test
581 version: 1.0.0
582 paths: {}
583 "})
584 .unwrap();
585
586 let manifest = default_manifest().apply(CargoManifestDiff {
587 dependencies: Some({
588 let mut deps = BTreeMap::new();
589 deps.insert(
590 "serde".to_owned(),
591 Dependency::Simple(Version::new(1, 0, 0)),
592 );
593 deps
594 }),
595 ..Default::default()
596 });
597
598 let arena = Arena::new();
599 let spec = Spec::from_doc(&arena, &doc).unwrap();
600 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
601 let manifest = CodegenCargoManifest::new(&graph, &manifest).to_manifest();
602
603 assert_eq!(
604 manifest.to_string(),
605 indoc::formatdoc! {r#"
606 [package]
607 name = "test-client"
608 version = "0.1.0"
609 edition = "2024"
610
611 [dependencies]
612 serde = "1.0.0"
613 ploidy-util = "{PLOIDY_VERSION}"
614 "#},
615 );
616 }
617
618 #[test]
619 fn test_apply_sets_features() {
620 let mut features = BTreeMap::new();
621 features.insert(
622 "default".to_owned(),
623 FeatureDependencies(vec!["customer".to_owned()]),
624 );
625 features.insert("customer".to_owned(), FeatureDependencies(vec![]));
626 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
627 features: Some(features),
628 ..Default::default()
629 });
630 let f = manifest.features();
631 assert_eq!(f["default"], vec!["customer"]);
632 assert_eq!(f["customer"], Vec::<String>::new());
633 }
634
635 #[test]
636 fn test_apply_preserves_untouched_fields() {
637 let manifest = CargoManifest::parse(indoc::indoc! {r#"
638 [package]
639 name = "pkg"
640 version = "1.0.0"
641 edition = "2021"
642
643 [profile.release]
644 lto = true
645 "#})
646 .unwrap()
647 .apply(CargoManifestDiff {
648 edition: Some(RustEdition::E2024),
649 ..Default::default()
650 });
651 assert_eq!(
652 manifest.to_string(),
653 indoc::indoc! {r#"
654 [package]
655 name = "pkg"
656 version = "1.0.0"
657 edition = "2024"
658
659 [profile.release]
660 lto = true
661 "#},
662 );
663 }
664
665 #[test]
666 fn test_config_returns_none_when_absent() {
667 let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0));
668 let pkg = manifest.package().unwrap();
669 assert_matches!(pkg.config(), Ok(None));
670 }
671
672 #[test]
673 fn test_config_deserializes_codegen_config() {
674 let manifest = CargoManifest::parse(indoc::indoc! {r#"
675 [package]
676 name = "pkg"
677 version = "1.0.0"
678 edition = "2024"
679
680 [package.metadata.ploidy]
681 date-time-format = "unix-seconds"
682 "#})
683 .unwrap();
684 let pkg = manifest.package().unwrap();
685 let config = pkg.config().unwrap().unwrap();
686 assert_eq!(config.date_time_format, DateTimeFormat::UnixSeconds);
687 }
688
689 #[test]
692 fn test_schema_with_x_resource_id_creates_feature() {
693 let doc = Document::from_yaml(indoc::indoc! {"
694 openapi: 3.0.0
695 info:
696 title: Test
697 version: 1.0.0
698 components:
699 schemas:
700 Customer:
701 type: object
702 x-resourceId: customer
703 properties:
704 id:
705 type: string
706 "})
707 .unwrap();
708
709 let arena = Arena::new();
710 let spec = Spec::from_doc(&arena, &doc).unwrap();
711 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
712 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
713
714 let features = manifest.features();
715 let keys = features.keys().copied().collect_vec();
716 assert_matches!(&*keys, ["customer", "default"]);
717 }
718
719 #[test]
720 fn test_operation_with_x_resource_name_creates_feature() {
721 let doc = Document::from_yaml(indoc::indoc! {"
722 openapi: 3.0.0
723 info:
724 title: Test
725 version: 1.0.0
726 paths:
727 /pets:
728 get:
729 operationId: listPets
730 x-resource-name: pets
731 responses:
732 '200':
733 description: OK
734 "})
735 .unwrap();
736
737 let arena = Arena::new();
738 let spec = Spec::from_doc(&arena, &doc).unwrap();
739 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
740 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
741
742 let features = manifest.features();
743 let keys = features.keys().copied().collect_vec();
744 assert_matches!(&*keys, ["default", "pets"]);
745 }
746
747 #[test]
748 fn test_resource_feature_names_deduplicate_numeric_case_collisions() {
749 let doc = Document::from_yaml(indoc::indoc! {"
750 openapi: 3.0.0
751 info:
752 title: Test
753 version: 1.0.0
754 paths:
755 /tokens:
756 get:
757 operationId: listTokens
758 x-resource-name: oauth_2_token
759 responses:
760 '200':
761 description: OK
762 components:
763 schemas:
764 OAuth2Token:
765 type: object
766 x-resourceId: oauth2Token
767 properties:
768 id:
769 type: string
770 "})
771 .unwrap();
772
773 let arena = Arena::new();
774 let spec = Spec::from_doc(&arena, &doc).unwrap();
775 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
776 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
777
778 let features = manifest.features();
779 let keys = features.keys().copied().collect_vec();
780 assert_matches!(&*keys, ["default", "oauth-2-token-2", "oauth2-token"]);
781 assert_eq!(features["default"], ["oauth-2-token-2", "oauth2-token"]);
782 }
783
784 #[test]
785 fn test_unnamed_schema_creates_no_features() {
786 let doc = Document::from_yaml(indoc::indoc! {"
787 openapi: 3.0.0
788 info:
789 title: Test
790 version: 1.0.0
791 components:
792 schemas:
793 Simple:
794 type: object
795 properties:
796 id:
797 type: string
798 "})
799 .unwrap();
800
801 let arena = Arena::new();
802 let spec = Spec::from_doc(&arena, &doc).unwrap();
803 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
804 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
805
806 let features = manifest.features();
807 let keys = features.keys().copied().collect_vec();
808 assert_matches!(&*keys, []);
809 }
810
811 #[test]
814 fn test_schema_dependency_creates_feature_dependency() {
815 let doc = Document::from_yaml(indoc::indoc! {"
816 openapi: 3.0.0
817 info:
818 title: Test
819 version: 1.0.0
820 components:
821 schemas:
822 Customer:
823 type: object
824 x-resourceId: customer
825 properties:
826 billing:
827 $ref: '#/components/schemas/BillingInfo'
828 BillingInfo:
829 type: object
830 x-resourceId: billing
831 properties:
832 card:
833 type: string
834 "})
835 .unwrap();
836
837 let arena = Arena::new();
838 let spec = Spec::from_doc(&arena, &doc).unwrap();
839 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
840 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
841
842 let features = manifest.features();
845 assert_eq!(features["customer"], ["billing"]);
846 }
847
848 #[test]
849 fn test_transitive_schema_dependency_creates_feature_dependency() {
850 let doc = Document::from_yaml(indoc::indoc! {"
851 openapi: 3.0.0
852 info:
853 title: Test
854 version: 1.0.0
855 components:
856 schemas:
857 Order:
858 type: object
859 x-resourceId: orders
860 properties:
861 customer:
862 $ref: '#/components/schemas/Customer'
863 Customer:
864 type: object
865 x-resourceId: customer
866 properties:
867 billing:
868 $ref: '#/components/schemas/BillingInfo'
869 BillingInfo:
870 type: object
871 x-resourceId: billing
872 properties:
873 card:
874 type: string
875 "})
876 .unwrap();
877
878 let arena = Arena::new();
879 let spec = Spec::from_doc(&arena, &doc).unwrap();
880 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
881 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
882
883 let features = manifest.features();
886 assert_eq!(features["orders"], ["billing", "customer"]);
887 }
888
889 #[test]
890 fn test_unnamed_dependency_does_not_create_feature_dependency() {
891 let doc = Document::from_yaml(indoc::indoc! {"
892 openapi: 3.0.0
893 info:
894 title: Test
895 version: 1.0.0
896 components:
897 schemas:
898 Customer:
899 type: object
900 x-resourceId: customer
901 properties:
902 address:
903 $ref: '#/components/schemas/Address'
904 Address:
905 type: object
906 properties:
907 street:
908 type: string
909 "})
910 .unwrap();
911
912 let arena = Arena::new();
913 let spec = Spec::from_doc(&arena, &doc).unwrap();
914 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
915 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
916
917 let features = manifest.features();
921 assert_matches!(&*features["customer"], &[]);
922 }
923
924 #[test]
925 fn test_feature_does_not_depend_on_itself() {
926 let doc = Document::from_yaml(indoc::indoc! {"
927 openapi: 3.0.0
928 info:
929 title: Test
930 version: 1.0.0
931 components:
932 schemas:
933 Node:
934 type: object
935 x-resourceId: nodes
936 properties:
937 children:
938 type: array
939 items:
940 $ref: '#/components/schemas/Node'
941 "})
942 .unwrap();
943
944 let arena = Arena::new();
945 let spec = Spec::from_doc(&arena, &doc).unwrap();
946 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
947 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
948
949 let features = manifest.features();
951 assert_matches!(&*features["nodes"], []);
952 }
953
954 #[test]
955 fn test_schema_dependency_on_own_resource_does_not_create_feature_dependency() {
956 let doc = Document::from_yaml(indoc::indoc! {"
957 openapi: 3.0.0
958 info:
959 title: Test
960 version: 1.0.0
961 components:
962 schemas:
963 Default:
964 type: object
965 x-resourceId: default
966 properties:
967 child:
968 $ref: '#/components/schemas/DefaultChild'
969 DefaultChild:
970 type: object
971 x-resourceId: default
972 properties:
973 id:
974 type: string
975 "})
976 .unwrap();
977
978 let arena = Arena::new();
979 let spec = Spec::from_doc(&arena, &doc).unwrap();
980 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
981 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
982
983 let features = manifest.features();
984 assert_matches!(&*features["default-2"], []);
985 assert_matches!(&*features["default"], ["default-2"]);
986 }
987
988 #[test]
991 fn test_operation_type_dependency_creates_feature_dependency() {
992 let doc = Document::from_yaml(indoc::indoc! {"
993 openapi: 3.0.0
994 info:
995 title: Test
996 version: 1.0.0
997 paths:
998 /orders:
999 get:
1000 operationId: listOrders
1001 x-resource-name: orders
1002 responses:
1003 '200':
1004 description: OK
1005 content:
1006 application/json:
1007 schema:
1008 type: array
1009 items:
1010 $ref: '#/components/schemas/Order'
1011 components:
1012 schemas:
1013 Order:
1014 type: object
1015 properties:
1016 customer:
1017 $ref: '#/components/schemas/Customer'
1018 Customer:
1019 type: object
1020 x-resourceId: customer
1021 properties:
1022 id:
1023 type: string
1024 "})
1025 .unwrap();
1026
1027 let arena = Arena::new();
1028 let spec = Spec::from_doc(&arena, &doc).unwrap();
1029 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1030 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1031
1032 let features = manifest.features();
1035 assert_eq!(features["orders"], ["customer"]);
1036 }
1037
1038 #[test]
1039 fn test_operation_with_unnamed_type_dependency_does_not_create_full_dependency() {
1040 let doc = Document::from_yaml(indoc::indoc! {"
1041 openapi: 3.0.0
1042 info:
1043 title: Test
1044 version: 1.0.0
1045 paths:
1046 /customers:
1047 get:
1048 operationId: listCustomers
1049 x-resource-name: customer
1050 responses:
1051 '200':
1052 description: OK
1053 content:
1054 application/json:
1055 schema:
1056 type: array
1057 items:
1058 $ref: '#/components/schemas/Customer'
1059 components:
1060 schemas:
1061 Customer:
1062 type: object
1063 properties:
1064 address:
1065 $ref: '#/components/schemas/Address'
1066 Address:
1067 type: object
1068 properties:
1069 street:
1070 type: string
1071 "})
1072 .unwrap();
1073
1074 let arena = Arena::new();
1075 let spec = Spec::from_doc(&arena, &doc).unwrap();
1076 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1077 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1078
1079 let features = manifest.features();
1082 assert_matches!(&*features["customer"], []);
1083 }
1084
1085 #[test]
1086 fn test_operation_dependency_on_own_resource_does_not_create_feature_dependency() {
1087 let doc = Document::from_yaml(indoc::indoc! {"
1088 openapi: 3.0.0
1089 info:
1090 title: Test
1091 version: 1.0.0
1092 paths:
1093 /defaults:
1094 get:
1095 operationId: listDefaults
1096 x-resource-name: default
1097 responses:
1098 '200':
1099 description: OK
1100 content:
1101 application/json:
1102 schema:
1103 type: array
1104 items:
1105 $ref: '#/components/schemas/Default'
1106 components:
1107 schemas:
1108 Default:
1109 type: object
1110 x-resourceId: default
1111 properties:
1112 id:
1113 type: string
1114 "})
1115 .unwrap();
1116
1117 let arena = Arena::new();
1118 let spec = Spec::from_doc(&arena, &doc).unwrap();
1119 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1120 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1121
1122 let features = manifest.features();
1123 assert_matches!(&*features["default-2"], []);
1124 assert_matches!(&*features["default"], ["default-2"]);
1125 }
1126
1127 #[test]
1130 fn test_diamond_dependency_deduplicates_feature() {
1131 let doc = Document::from_yaml(indoc::indoc! {"
1134 openapi: 3.0.0
1135 info:
1136 title: Test
1137 version: 1.0.0
1138 components:
1139 schemas:
1140 A:
1141 type: object
1142 x-resourceId: a
1143 properties:
1144 b:
1145 $ref: '#/components/schemas/B'
1146 c:
1147 $ref: '#/components/schemas/C'
1148 B:
1149 type: object
1150 x-resourceId: b
1151 properties:
1152 d:
1153 $ref: '#/components/schemas/D'
1154 C:
1155 type: object
1156 x-resourceId: c
1157 properties:
1158 d:
1159 $ref: '#/components/schemas/D'
1160 D:
1161 type: object
1162 x-resourceId: d
1163 properties:
1164 value:
1165 type: string
1166 "})
1167 .unwrap();
1168
1169 let arena = Arena::new();
1170 let spec = Spec::from_doc(&arena, &doc).unwrap();
1171 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1172 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1173
1174 let features = manifest.features();
1175
1176 assert_eq!(features["a"], ["b", "c", "d"]);
1179
1180 assert_eq!(features["b"], ["d"]);
1182 assert_eq!(features["c"], ["d"]);
1183
1184 assert_matches!(&*features["d"], []);
1186 }
1187
1188 #[test]
1191 fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
1192 let doc = Document::from_yaml(indoc::indoc! {"
1196 openapi: 3.0.0
1197 info:
1198 title: Test
1199 version: 1.0.0
1200 components:
1201 schemas:
1202 A:
1203 type: object
1204 x-resourceId: a
1205 properties:
1206 b:
1207 $ref: '#/components/schemas/B'
1208 B:
1209 type: object
1210 properties:
1211 c:
1212 $ref: '#/components/schemas/C'
1213 C:
1214 type: object
1215 x-resourceId: c
1216 properties:
1217 a:
1218 $ref: '#/components/schemas/A'
1219 "})
1220 .unwrap();
1221
1222 let arena = Arena::new();
1223 let spec = Spec::from_doc(&arena, &doc).unwrap();
1224 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1225 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1226
1227 let features = manifest.features();
1228
1229 assert_eq!(features["a"], ["c"]);
1232
1233 assert_eq!(features["c"], ["a"]);
1235
1236 assert_eq!(features["default"], ["a", "c"]);
1238 }
1239
1240 #[test]
1241 fn test_cycle_with_all_named_resources_preserves_feature_members() {
1242 let doc = Document::from_yaml(indoc::indoc! {"
1245 openapi: 3.0.0
1246 info:
1247 title: Test
1248 version: 1.0.0
1249 components:
1250 schemas:
1251 A:
1252 type: object
1253 x-resourceId: a
1254 properties:
1255 b:
1256 $ref: '#/components/schemas/B'
1257 B:
1258 type: object
1259 x-resourceId: b
1260 properties:
1261 c:
1262 $ref: '#/components/schemas/C'
1263 C:
1264 type: object
1265 x-resourceId: c
1266 properties:
1267 a:
1268 $ref: '#/components/schemas/A'
1269 "})
1270 .unwrap();
1271
1272 let arena = Arena::new();
1273 let spec = Spec::from_doc(&arena, &doc).unwrap();
1274 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1275 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1276
1277 let features = manifest.features();
1278
1279 assert_eq!(features["a"], ["b", "c"]);
1281
1282 assert_eq!(features["b"], ["a", "c"]);
1284
1285 assert_eq!(features["c"], ["a", "b"]);
1287
1288 assert_eq!(features["default"], ["a", "b", "c"]);
1290 }
1291
1292 #[test]
1295 fn test_default_feature_includes_all_other_features() {
1296 let doc = Document::from_yaml(indoc::indoc! {"
1297 openapi: 3.0.0
1298 info:
1299 title: Test
1300 version: 1.0.0
1301 paths:
1302 /pets:
1303 get:
1304 operationId: listPets
1305 x-resource-name: pets
1306 responses:
1307 '200':
1308 description: OK
1309 components:
1310 schemas:
1311 Customer:
1312 type: object
1313 x-resourceId: customer
1314 properties:
1315 id:
1316 type: string
1317 Order:
1318 type: object
1319 x-resourceId: orders
1320 properties:
1321 id:
1322 type: string
1323 "})
1324 .unwrap();
1325
1326 let arena = Arena::new();
1327 let spec = Spec::from_doc(&arena, &doc).unwrap();
1328 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1329 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1330
1331 let features = manifest.features();
1334 assert_eq!(features["default"], ["customer", "orders", "pets"]);
1335 }
1336
1337 #[test]
1338 fn test_default_feature_includes_all_named_features() {
1339 let doc = Document::from_yaml(indoc::indoc! {"
1340 openapi: 3.0.0
1341 info:
1342 title: Test
1343 version: 1.0.0
1344 components:
1345 schemas:
1346 Customer:
1347 type: object
1348 x-resourceId: customer
1349 properties:
1350 id:
1351 type: string
1352 "})
1353 .unwrap();
1354
1355 let arena = Arena::new();
1356 let spec = Spec::from_doc(&arena, &doc).unwrap();
1357 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1358 let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1359
1360 let features = manifest.features();
1362 assert_eq!(features["default"], ["customer"]);
1363 }
1364}