Skip to main content

ploidy_codegen_rust/
cargo.rs

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::CargoFeature};
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        // Translate resource names from operations and schemas into
34        // Cargo feature names with dependencies.
35        let features = {
36            let mut deps_by_feature = BTreeMap::new();
37
38            // For each schema type with an explicitly declared resource name,
39            // use the resource name as the feature name, and enable features
40            // for all its transitive dependencies.
41            for schema in self.graph.schemas() {
42                let feature = match schema.resource().map(CargoFeature::from_name) {
43                    Some(CargoFeature::Named(name)) => CargoFeature::Named(name),
44                    _ => continue,
45                };
46                let entry: &mut BTreeSet<_> = deps_by_feature.entry(feature).or_default();
47                for dep in schema.dependencies().filter_map(|ty| {
48                    match CargoFeature::from_name(ty.into_schema().ok()?.resource()?) {
49                        CargoFeature::Named(name) => Some(CargoFeature::Named(name)),
50                        CargoFeature::Default => None,
51                    }
52                }) {
53                    entry.insert(dep);
54                }
55            }
56
57            // For each operation with an explicitly declared resource name,
58            // use the resource name as the feature name, and enable features for
59            // all the types that are reachable from the operation.
60            for op in self.graph.operations() {
61                let feature = match op.resource().map(CargoFeature::from_name) {
62                    Some(CargoFeature::Named(name)) => CargoFeature::Named(name),
63                    _ => continue,
64                };
65                let entry = deps_by_feature.entry(feature).or_default();
66                for dep in op.dependencies().filter_map(|ty| {
67                    match CargoFeature::from_name(ty.into_schema().ok()?.resource()?) {
68                        CargoFeature::Named(name) => Some(CargoFeature::Named(name)),
69                        CargoFeature::Default => None,
70                    }
71                }) {
72                    entry.insert(dep);
73                }
74            }
75
76            // Build the `features` section of the manifest.
77            let mut features: BTreeMap<_, _> = deps_by_feature
78                .iter()
79                .map(|(feature, deps)| {
80                    (
81                        feature.display().to_string(),
82                        FeatureDependencies(
83                            deps.iter()
84                                .map(|dep| dep.display().to_string())
85                                .collect_vec(),
86                        ),
87                    )
88                })
89                .collect();
90            if features.is_empty() {
91                BTreeMap::new()
92            } else {
93                // `default` enables all other features.
94                features.insert(
95                    "default".to_owned(),
96                    FeatureDependencies(
97                        deps_by_feature
98                            .keys()
99                            .map(|feature| feature.display().to_string())
100                            .collect_vec(),
101                    ),
102                );
103                features
104            }
105        };
106
107        self.manifest.clone().apply(CargoManifestDiff {
108            // Ploidy generates Rust 2024-compatible code.
109            edition: Some(RustEdition::E2024),
110            dependencies: Some(BTreeMap::from_iter([
111                // `ploidy-util` is our only runtime dependency.
112                (
113                    "ploidy-util".to_owned(),
114                    Dependency::Simple(PLOIDY_VERSION.parse().unwrap()),
115                ),
116            ])),
117            features: Some(features),
118            ..Default::default()
119        })
120    }
121}
122
123impl Code for CodegenCargoManifest<'_> {
124    fn path(&self) -> &str {
125        "Cargo.toml"
126    }
127
128    fn into_string(self) -> miette::Result<String> {
129        Ok(self.to_manifest().to_string())
130    }
131}
132
133/// A `Cargo.toml` manifest.
134#[derive(Clone, Debug)]
135pub struct CargoManifest(DocumentMut);
136
137impl CargoManifest {
138    /// Creates a Cargo manifest with the given package `name` and `version`.
139    pub fn new(name: &str, version: Version) -> Self {
140        let package = Table::from_iter([
141            ("name", value(name)),
142            ("version", value(version.to_string())),
143            ("edition", value(RustEdition::E2024)),
144        ]);
145        let manifest = Table::from_iter([("package", package)]);
146        Self(manifest.into())
147    }
148
149    /// Reads and parses an existing Cargo manifest from disk.
150    pub fn from_disk(path: &Path) -> Result<Self, CargoManifestError> {
151        let contents = std::fs::read_to_string(path)?;
152        Self::parse(&contents)
153    }
154
155    /// Parses a Cargo manifest from a TOML string.
156    pub fn parse(s: &str) -> Result<Self, CargoManifestError> {
157        Ok(Self(s.parse().map_err(
158            |source: toml_edit::TomlError| {
159                let span = source.span().map(SourceSpan::from);
160                SpannedError {
161                    source: Box::new(source),
162                    code: s.to_owned(),
163                    span,
164                }
165            },
166        )?))
167    }
168
169    /// Returns a view of the `package` section, or `None` if this is
170    /// a workspace or malformed manifest.
171    #[inline]
172    pub fn package(&self) -> Option<Package<'_>> {
173        let package = self.0.get("package")?.as_table_like()?;
174        let name = package.get("name")?;
175        let version = package.get("version")?;
176        Some(Package {
177            name: SpannedValue::new(name.as_str()?, &self.0, name.span()),
178            version: SpannedValue::new(version.as_str()?, &self.0, version.span()),
179            metadata: package
180                .get("metadata")
181                .and_then(|meta| Some((meta.as_table_like()?, meta.span())))
182                .map(|(meta, range)| SpannedValue::new(meta, &self.0, range)),
183        })
184    }
185
186    /// Returns the `features` table.
187    pub fn features(&self) -> BTreeMap<&str, Vec<&str>> {
188        self.0
189            .get("features")
190            .and_then(|features| features.as_table_like())
191            .into_iter()
192            .flat_map(|features| features.iter())
193            .map(|(name, item)| {
194                let deps = item
195                    .as_array()
196                    .into_iter()
197                    .flat_map(|deps| deps.iter())
198                    .filter_map(|dep| dep.as_str())
199                    .collect_vec();
200                (name, deps)
201            })
202            .collect()
203    }
204
205    /// Applies a diff of changes to the manifest.
206    pub fn apply(mut self, diff: CargoManifestDiff) -> Self {
207        let package = &mut self.0["package"];
208        if let Some(name) = diff.name {
209            package["name"] = value(name);
210        }
211        if let Some(version) = diff.version {
212            package["version"] = value(version.to_string());
213        }
214        if let Some(edition) = diff.edition {
215            package["edition"] = value(edition);
216        }
217        if let Some(deps) = diff.dependencies.filter(|f| !f.is_empty()) {
218            let table = self.0["dependencies"].or_insert(Table::new().into());
219            for (name, dep) in deps {
220                dep.merge_into(&mut table[&name]);
221            }
222        }
223        if let Some(features) = diff.features.filter(|f| !f.is_empty()) {
224            let table = self.0["features"].or_insert(Table::new().into());
225            for (name, feature) in features {
226                feature.merge_into(&mut table[&name]);
227            }
228        }
229        self
230    }
231}
232
233impl Display for CargoManifest {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        write!(f, "{}", self.0)
236    }
237}
238
239/// A view of the `package` section of a Cargo manifest.
240#[derive(Clone, Copy)]
241pub struct Package<'a> {
242    name: SpannedValue<'a, &'a str>,
243    version: SpannedValue<'a, &'a str>,
244    metadata: Option<SpannedValue<'a, &'a dyn TableLike>>,
245}
246
247impl<'a> Package<'a> {
248    /// Returns the package name.
249    pub fn name(&self) -> &'a str {
250        self.name.value
251    }
252
253    /// Parses and returns the package version.
254    pub fn version(&self) -> Result<Version, SpannedError<PackageError>> {
255        Version::parse(self.version.value).map_err(|err| SpannedError {
256            source: Box::new(PackageError::from(err)),
257            code: self.version.source.to_string(),
258            span: self.version.span,
259        })
260    }
261
262    /// Deserializes `package.metadata.ploidy` into a [`CodegenConfig`].
263    /// Returns `Ok(None)` if the section is absent, or `Err` if
264    /// it's present but malformed.
265    pub fn config(&self) -> Result<Option<CodegenConfig>, SpannedError<PackageError>> {
266        let meta = match self.metadata {
267            Some(meta) => meta,
268            None => return Ok(None),
269        };
270        let table: Table = match meta.value.get("ploidy").and_then(|v| v.as_table_like()) {
271            Some(table) => table.iter().collect(),
272            None => return Ok(None),
273        };
274        let value: toml_edit::Value = table.into_inline_table().into();
275        let config =
276            CodegenConfig::deserialize(value.into_deserializer()).map_err(|err| SpannedError {
277                source: Box::new(PackageError::from(err)),
278                code: meta.source.to_string(),
279                span: meta.span,
280            })?;
281        Ok(Some(config))
282    }
283}
284
285impl Debug for Package<'_> {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("Package")
288            .field("name", &self.name)
289            .field("version", &self.version)
290            .finish_non_exhaustive()
291    }
292}
293
294/// A TOML value with source location information for diagnostics.
295#[derive(Clone, Copy, Debug)]
296struct SpannedValue<'a, T> {
297    source: &'a DocumentMut,
298    value: T,
299    span: Option<SourceSpan>,
300}
301
302impl<'a, T> SpannedValue<'a, T> {
303    fn new(value: T, source: &'a DocumentMut, range: Option<Range<usize>>) -> Self {
304        Self {
305            source,
306            value,
307            span: range.map(SourceSpan::from),
308        }
309    }
310}
311
312/// An error with source location information for diagnostics.
313#[derive(Debug, miette::Diagnostic)]
314pub struct SpannedError<E: StdError + Send + Sync + 'static> {
315    source: Box<E>,
316    #[source_code]
317    code: String,
318    #[label]
319    span: Option<SourceSpan>,
320}
321
322impl<E: StdError + Send + Sync + 'static> Display for SpannedError<E> {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        Display::fmt(&self.source, f)
325    }
326}
327
328impl<E: StdError + Send + Sync + 'static> StdError for SpannedError<E> {
329    fn source(&self) -> Option<&(dyn StdError + 'static)> {
330        // Equivalent to the generated implementation for
331        // `#[error(transparent)]`.
332        self.source.source()
333    }
334}
335
336/// The Rust edition that a package is compiled with.
337#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
338pub enum RustEdition {
339    E2021,
340    #[default]
341    E2024,
342}
343
344impl From<RustEdition> for toml_edit::Value {
345    fn from(edition: RustEdition) -> Self {
346        toml_edit::Value::from(match edition {
347            RustEdition::E2021 => "2021",
348            RustEdition::E2024 => "2024",
349        })
350    }
351}
352
353/// A diff of changes to apply to a [`CargoManifest`].
354#[derive(Clone, Debug, Default)]
355pub struct CargoManifestDiff {
356    pub name: Option<String>,
357    pub version: Option<Version>,
358    pub edition: Option<RustEdition>,
359    pub dependencies: Option<BTreeMap<String, Dependency>>,
360    pub features: Option<BTreeMap<String, FeatureDependencies>>,
361}
362
363/// An entry in the `dependencies` section of a Cargo manifest.
364#[derive(Clone, Debug)]
365pub enum Dependency {
366    Simple(Version),
367    Detailed(DependencyDetail),
368}
369
370impl Dependency {
371    /// Merges this dependency into an existing manifest entry. If the entry is
372    /// already a table, only the specified fields are updated; if it's
373    /// absent or a simple version string, it's replaced.
374    fn merge_into(self, entry: &mut toml_edit::Item) {
375        match self {
376            Dependency::Simple(version) => {
377                if let Some(table) = entry.as_table_like_mut() {
378                    table.insert("version", value(version.to_string()));
379                } else {
380                    *entry = value(version.to_string());
381                }
382            }
383            Dependency::Detailed(detail) => {
384                let table = match entry.as_table_like_mut() {
385                    Some(table) => table,
386                    None => {
387                        *entry = InlineTable::new().into();
388                        entry.as_table_like_mut().unwrap()
389                    }
390                };
391                table.insert("version", value(detail.version.to_string()));
392                if let Some(path) = detail.path {
393                    table.insert("path", value(path));
394                }
395            }
396        }
397    }
398}
399
400#[derive(Clone, Debug)]
401pub struct DependencyDetail {
402    pub version: Version,
403    pub path: Option<String>,
404}
405
406/// A set of feature dependencies to merge into a `[features]` entry.
407#[derive(Clone, Debug)]
408pub struct FeatureDependencies(Vec<String>);
409
410impl FeatureDependencies {
411    /// Merges these feature dependencies into an existing manifest entry.
412    /// If the entry is already an array, all its existing dependencies
413    /// are preserved, and only new ones are added; if it's absent,
414    /// the array is created.
415    fn merge_into(self, entry: &mut toml_edit::Item) {
416        match entry.as_array_mut() {
417            Some(array) => {
418                let existing: BTreeSet<_> = array.iter().filter_map(|dep| dep.as_str()).collect();
419                let new = self
420                    .0
421                    .into_iter()
422                    .filter(|dep| !existing.contains(dep.as_str()))
423                    .collect_vec();
424                array.extend(new);
425            }
426            None => {
427                *entry = Array::from_iter(self.0).into();
428            }
429        }
430    }
431}
432
433#[derive(Debug, thiserror::Error)]
434pub enum CargoManifestError {
435    #[error(transparent)]
436    Io(#[from] std::io::Error),
437
438    #[error(transparent)]
439    Parse(#[from] SpannedError<toml_edit::TomlError>),
440}
441
442#[derive(Debug, thiserror::Error)]
443pub enum PackageError {
444    #[error(transparent)]
445    Deserialize(#[from] toml_edit::de::Error),
446
447    #[error(transparent)]
448    Semver(#[from] semver::Error),
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    use ploidy_core::{
456        arena::Arena,
457        ir::{RawGraph, Spec},
458        parse::Document,
459    };
460
461    use crate::{config::DateTimeFormat, tests::assert_matches};
462
463    fn default_manifest() -> CargoManifest {
464        CargoManifest::new("test-client", Version::new(0, 1, 0))
465    }
466
467    // MARK: Manifest and TOML types
468
469    #[test]
470    fn test_new_manifest_has_package_name_version_and_edition() {
471        assert_eq!(
472            CargoManifest::new("my-crate", Version::new(1, 0, 0)).to_string(),
473            indoc::indoc! {r#"
474                [package]
475                name = "my-crate"
476                version = "1.0.0"
477                edition = "2024"
478            "#},
479        );
480    }
481
482    #[test]
483    fn test_package_returns_none_for_workspace() {
484        let manifest = CargoManifest::parse(indoc::indoc! {r#"
485            [workspace]
486            members = ["a"]
487        "#})
488        .unwrap();
489        assert!(manifest.package().is_none());
490    }
491
492    #[test]
493    fn test_apply_sets_name() {
494        let manifest = CargoManifest::new("old", Version::new(1, 0, 0)).apply(CargoManifestDiff {
495            name: Some("new".to_owned()),
496            ..Default::default()
497        });
498        assert_eq!(manifest.package().unwrap().name.value, "new");
499    }
500
501    #[test]
502    fn test_apply_sets_version() {
503        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
504            version: Some(Version::new(2, 0, 0)),
505            ..Default::default()
506        });
507        assert_eq!(manifest.package().unwrap().version.value, "2.0.0");
508    }
509
510    #[test]
511    fn test_apply_sets_edition() {
512        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
513            edition: Some(RustEdition::E2021),
514            ..Default::default()
515        });
516        assert_eq!(
517            manifest.to_string(),
518            indoc::indoc! {r#"
519                [package]
520                name = "pkg"
521                version = "1.0.0"
522                edition = "2021"
523            "#},
524        );
525    }
526
527    #[test]
528    fn test_apply_sets_simple_dependency() {
529        let mut deps = BTreeMap::new();
530        deps.insert(
531            "serde".to_owned(),
532            Dependency::Simple(Version::new(1, 0, 0)),
533        );
534        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
535            dependencies: Some(deps),
536            ..Default::default()
537        });
538        assert_eq!(
539            manifest.to_string(),
540            indoc::indoc! {r#"
541                [package]
542                name = "pkg"
543                version = "1.0.0"
544                edition = "2024"
545
546                [dependencies]
547                serde = "1.0.0"
548            "#},
549        );
550    }
551
552    #[test]
553    fn test_apply_sets_detailed_dependency() {
554        let mut deps = BTreeMap::new();
555        deps.insert(
556            "ploidy-util".to_owned(),
557            Dependency::Detailed(DependencyDetail {
558                version: Version::new(0, 10, 0),
559                path: Some("../ploidy-util".to_owned()),
560            }),
561        );
562        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
563            dependencies: Some(deps),
564            ..Default::default()
565        });
566        assert_eq!(
567            manifest.to_string(),
568            indoc::indoc! {r#"
569                [package]
570                name = "pkg"
571                version = "1.0.0"
572                edition = "2024"
573
574                [dependencies]
575                ploidy-util = { version = "0.10.0", path = "../ploidy-util" }
576            "#},
577        );
578    }
579
580    #[test]
581    fn test_apply_preserves_existing_dependencies() {
582        let doc = Document::from_yaml(indoc::indoc! {"
583            openapi: 3.0.0
584            info:
585              title: Test
586              version: 1.0.0
587            paths: {}
588        "})
589        .unwrap();
590
591        let manifest = default_manifest().apply(CargoManifestDiff {
592            dependencies: Some({
593                let mut deps = BTreeMap::new();
594                deps.insert(
595                    "serde".to_owned(),
596                    Dependency::Simple(Version::new(1, 0, 0)),
597                );
598                deps
599            }),
600            ..Default::default()
601        });
602
603        let arena = Arena::new();
604        let spec = Spec::from_doc(&arena, &doc).unwrap();
605        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
606        let manifest = CodegenCargoManifest::new(&graph, &manifest).to_manifest();
607
608        assert_eq!(
609            manifest.to_string(),
610            indoc::formatdoc! {r#"
611                [package]
612                name = "test-client"
613                version = "0.1.0"
614                edition = "2024"
615
616                [dependencies]
617                serde = "1.0.0"
618                ploidy-util = "{PLOIDY_VERSION}"
619            "#},
620        );
621    }
622
623    #[test]
624    fn test_apply_sets_features() {
625        let mut features = BTreeMap::new();
626        features.insert(
627            "default".to_owned(),
628            FeatureDependencies(vec!["customer".to_owned()]),
629        );
630        features.insert("customer".to_owned(), FeatureDependencies(vec![]));
631        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0)).apply(CargoManifestDiff {
632            features: Some(features),
633            ..Default::default()
634        });
635        let f = manifest.features();
636        assert_eq!(f["default"], vec!["customer"]);
637        assert_eq!(f["customer"], Vec::<String>::new());
638    }
639
640    #[test]
641    fn test_apply_preserves_untouched_fields() {
642        let manifest = CargoManifest::parse(indoc::indoc! {r#"
643            [package]
644            name = "pkg"
645            version = "1.0.0"
646            edition = "2021"
647
648            [profile.release]
649            lto = true
650        "#})
651        .unwrap()
652        .apply(CargoManifestDiff {
653            edition: Some(RustEdition::E2024),
654            ..Default::default()
655        });
656        assert_eq!(
657            manifest.to_string(),
658            indoc::indoc! {r#"
659                [package]
660                name = "pkg"
661                version = "1.0.0"
662                edition = "2024"
663
664                [profile.release]
665                lto = true
666            "#},
667        );
668    }
669
670    #[test]
671    fn test_config_returns_none_when_absent() {
672        let manifest = CargoManifest::new("pkg", Version::new(1, 0, 0));
673        let pkg = manifest.package().unwrap();
674        assert_matches!(pkg.config(), Ok(None));
675    }
676
677    #[test]
678    fn test_config_deserializes_codegen_config() {
679        let manifest = CargoManifest::parse(indoc::indoc! {r#"
680            [package]
681            name = "pkg"
682            version = "1.0.0"
683            edition = "2024"
684
685            [package.metadata.ploidy]
686            date-time-format = "unix-seconds"
687        "#})
688        .unwrap();
689        let pkg = manifest.package().unwrap();
690        let config = pkg.config().unwrap().unwrap();
691        assert_eq!(config.date_time_format, DateTimeFormat::UnixSeconds);
692    }
693
694    // MARK: Feature collection
695
696    #[test]
697    fn test_schema_with_x_resource_id_creates_feature() {
698        let doc = Document::from_yaml(indoc::indoc! {"
699            openapi: 3.0.0
700            info:
701              title: Test
702              version: 1.0.0
703            components:
704              schemas:
705                Customer:
706                  type: object
707                  x-resourceId: customer
708                  properties:
709                    id:
710                      type: string
711        "})
712        .unwrap();
713
714        let arena = Arena::new();
715        let spec = Spec::from_doc(&arena, &doc).unwrap();
716        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
717        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
718
719        let features = manifest.features();
720        let keys = features.keys().copied().collect_vec();
721        assert_matches!(&*keys, ["customer", "default"]);
722    }
723
724    #[test]
725    fn test_operation_with_x_resource_name_creates_feature() {
726        let doc = Document::from_yaml(indoc::indoc! {"
727            openapi: 3.0.0
728            info:
729              title: Test
730              version: 1.0.0
731            paths:
732              /pets:
733                get:
734                  operationId: listPets
735                  x-resource-name: pets
736                  responses:
737                    '200':
738                      description: OK
739        "})
740        .unwrap();
741
742        let arena = Arena::new();
743        let spec = Spec::from_doc(&arena, &doc).unwrap();
744        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
745        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
746
747        let features = manifest.features();
748        let keys = features.keys().copied().collect_vec();
749        assert_matches!(&*keys, ["default", "pets"]);
750    }
751
752    #[test]
753    fn test_unnamed_schema_creates_no_features() {
754        let doc = Document::from_yaml(indoc::indoc! {"
755            openapi: 3.0.0
756            info:
757              title: Test
758              version: 1.0.0
759            components:
760              schemas:
761                Simple:
762                  type: object
763                  properties:
764                    id:
765                      type: string
766        "})
767        .unwrap();
768
769        let arena = Arena::new();
770        let spec = Spec::from_doc(&arena, &doc).unwrap();
771        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
772        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
773
774        let features = manifest.features();
775        let keys = features.keys().copied().collect_vec();
776        assert_matches!(&*keys, []);
777    }
778
779    // MARK: Schema feature dependencies
780
781    #[test]
782    fn test_schema_dependency_creates_feature_dependency() {
783        let doc = Document::from_yaml(indoc::indoc! {"
784            openapi: 3.0.0
785            info:
786              title: Test
787              version: 1.0.0
788            components:
789              schemas:
790                Customer:
791                  type: object
792                  x-resourceId: customer
793                  properties:
794                    billing:
795                      $ref: '#/components/schemas/BillingInfo'
796                BillingInfo:
797                  type: object
798                  x-resourceId: billing
799                  properties:
800                    card:
801                      type: string
802        "})
803        .unwrap();
804
805        let arena = Arena::new();
806        let spec = Spec::from_doc(&arena, &doc).unwrap();
807        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
808        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
809
810        // `Customer` depends on `BillingInfo`, so the `customer` feature
811        // should depend on `billing`.
812        let features = manifest.features();
813        assert_eq!(features["customer"], ["billing"]);
814    }
815
816    #[test]
817    fn test_transitive_schema_dependency_creates_feature_dependency() {
818        let doc = Document::from_yaml(indoc::indoc! {"
819            openapi: 3.0.0
820            info:
821              title: Test
822              version: 1.0.0
823            components:
824              schemas:
825                Order:
826                  type: object
827                  x-resourceId: orders
828                  properties:
829                    customer:
830                      $ref: '#/components/schemas/Customer'
831                Customer:
832                  type: object
833                  x-resourceId: customer
834                  properties:
835                    billing:
836                      $ref: '#/components/schemas/BillingInfo'
837                BillingInfo:
838                  type: object
839                  x-resourceId: billing
840                  properties:
841                    card:
842                      type: string
843        "})
844        .unwrap();
845
846        let arena = Arena::new();
847        let spec = Spec::from_doc(&arena, &doc).unwrap();
848        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
849        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
850
851        // `Order` → `Customer` → `BillingInfo`, so `order` should
852        // depend on both `customer` and `billing`.
853        let features = manifest.features();
854        assert_eq!(features["orders"], ["billing", "customer"]);
855    }
856
857    #[test]
858    fn test_unnamed_dependency_does_not_create_feature_dependency() {
859        let doc = Document::from_yaml(indoc::indoc! {"
860            openapi: 3.0.0
861            info:
862              title: Test
863              version: 1.0.0
864            components:
865              schemas:
866                Customer:
867                  type: object
868                  x-resourceId: customer
869                  properties:
870                    address:
871                      $ref: '#/components/schemas/Address'
872                Address:
873                  type: object
874                  properties:
875                    street:
876                      type: string
877        "})
878        .unwrap();
879
880        let arena = Arena::new();
881        let spec = Spec::from_doc(&arena, &doc).unwrap();
882        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
883        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
884
885        // `Customer` depends on `Address`, which doesn't have a resource.
886        // The `customer` feature should _not_ depend on `default`;
887        // that's handled via `cfg` attributes instead.
888        let features = manifest.features();
889        assert_matches!(&*features["customer"], &[]);
890    }
891
892    #[test]
893    fn test_feature_does_not_depend_on_itself() {
894        let doc = Document::from_yaml(indoc::indoc! {"
895            openapi: 3.0.0
896            info:
897              title: Test
898              version: 1.0.0
899            components:
900              schemas:
901                Node:
902                  type: object
903                  x-resourceId: nodes
904                  properties:
905                    children:
906                      type: array
907                      items:
908                        $ref: '#/components/schemas/Node'
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        // Self-referential schemas should not create self-dependencies.
918        let features = manifest.features();
919        assert_matches!(&*features["nodes"], []);
920    }
921
922    // MARK: Operation feature dependencies
923
924    #[test]
925    fn test_operation_type_dependency_creates_feature_dependency() {
926        let doc = Document::from_yaml(indoc::indoc! {"
927            openapi: 3.0.0
928            info:
929              title: Test
930              version: 1.0.0
931            paths:
932              /orders:
933                get:
934                  operationId: listOrders
935                  x-resource-name: orders
936                  responses:
937                    '200':
938                      description: OK
939                      content:
940                        application/json:
941                          schema:
942                            type: array
943                            items:
944                              $ref: '#/components/schemas/Order'
945            components:
946              schemas:
947                Order:
948                  type: object
949                  properties:
950                    customer:
951                      $ref: '#/components/schemas/Customer'
952                Customer:
953                  type: object
954                  x-resourceId: customer
955                  properties:
956                    id:
957                      type: string
958        "})
959        .unwrap();
960
961        let arena = Arena::new();
962        let spec = Spec::from_doc(&arena, &doc).unwrap();
963        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
964        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
965
966        // `listOrders` returns `Order`, which references `Customer`, so
967        // `orders` should depend on `customer`.
968        let features = manifest.features();
969        assert_eq!(features["orders"], ["customer"]);
970    }
971
972    #[test]
973    fn test_operation_with_unnamed_type_dependency_does_not_create_full_dependency() {
974        let doc = Document::from_yaml(indoc::indoc! {"
975            openapi: 3.0.0
976            info:
977              title: Test
978              version: 1.0.0
979            paths:
980              /customers:
981                get:
982                  operationId: listCustomers
983                  x-resource-name: customer
984                  responses:
985                    '200':
986                      description: OK
987                      content:
988                        application/json:
989                          schema:
990                            type: array
991                            items:
992                              $ref: '#/components/schemas/Customer'
993            components:
994              schemas:
995                Customer:
996                  type: object
997                  properties:
998                    address:
999                      $ref: '#/components/schemas/Address'
1000                Address:
1001                  type: object
1002                  properties:
1003                    street:
1004                      type: string
1005        "})
1006        .unwrap();
1007
1008        let arena = Arena::new();
1009        let spec = Spec::from_doc(&arena, &doc).unwrap();
1010        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1011        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1012
1013        // `listOrders` returns `Customer`, which references `Address`, but
1014        // `customer` should _not_ depend on `default`.
1015        let features = manifest.features();
1016        assert_matches!(&*features["customer"], []);
1017    }
1018
1019    // MARK: Diamond dependencies
1020
1021    #[test]
1022    fn test_diamond_dependency_deduplicates_feature() {
1023        // A -> B, A -> C, B -> D, C -> D. All have resources.
1024        // A's feature should depend on B, C, and D; D should appear once.
1025        let doc = Document::from_yaml(indoc::indoc! {"
1026            openapi: 3.0.0
1027            info:
1028              title: Test
1029              version: 1.0.0
1030            components:
1031              schemas:
1032                A:
1033                  type: object
1034                  x-resourceId: a
1035                  properties:
1036                    b:
1037                      $ref: '#/components/schemas/B'
1038                    c:
1039                      $ref: '#/components/schemas/C'
1040                B:
1041                  type: object
1042                  x-resourceId: b
1043                  properties:
1044                    d:
1045                      $ref: '#/components/schemas/D'
1046                C:
1047                  type: object
1048                  x-resourceId: c
1049                  properties:
1050                    d:
1051                      $ref: '#/components/schemas/D'
1052                D:
1053                  type: object
1054                  x-resourceId: d
1055                  properties:
1056                    value:
1057                      type: string
1058        "})
1059        .unwrap();
1060
1061        let arena = Arena::new();
1062        let spec = Spec::from_doc(&arena, &doc).unwrap();
1063        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1064        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1065
1066        let features = manifest.features();
1067
1068        // `a` depends directly on `b`, `c`;
1069        // transitively on `d` though `b` and `c`.
1070        assert_eq!(features["a"], ["b", "c", "d"]);
1071
1072        // `b` and `c` each depend on `d`.
1073        assert_eq!(features["b"], ["d"]);
1074        assert_eq!(features["c"], ["d"]);
1075
1076        // `d` has no dependencies.
1077        assert_matches!(&*features["d"], []);
1078    }
1079
1080    // MARK: Cycles with mixed resources
1081
1082    #[test]
1083    fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
1084        // Type A (resource `a`) -> Type B (no resource) -> Type C (resource `c`) -> Type A.
1085        // Since B doesn't have a resource, we don't create a dependency on it;
1086        // that's handled via `#[cfg(...)]` attributes.
1087        let doc = Document::from_yaml(indoc::indoc! {"
1088            openapi: 3.0.0
1089            info:
1090              title: Test
1091              version: 1.0.0
1092            components:
1093              schemas:
1094                A:
1095                  type: object
1096                  x-resourceId: a
1097                  properties:
1098                    b:
1099                      $ref: '#/components/schemas/B'
1100                B:
1101                  type: object
1102                  properties:
1103                    c:
1104                      $ref: '#/components/schemas/C'
1105                C:
1106                  type: object
1107                  x-resourceId: c
1108                  properties:
1109                    a:
1110                      $ref: '#/components/schemas/A'
1111        "})
1112        .unwrap();
1113
1114        let arena = Arena::new();
1115        let spec = Spec::from_doc(&arena, &doc).unwrap();
1116        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1117        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1118
1119        let features = manifest.features();
1120
1121        // A depends on B (unnamed) and C. Since B is unnamed,
1122        // A only depends on C.
1123        assert_eq!(features["a"], ["c"]);
1124
1125        // C depends on A (which depends on B, unnamed). C only depends on A.
1126        assert_eq!(features["c"], ["a"]);
1127
1128        // `default` should include both named features.
1129        assert_eq!(features["default"], ["a", "c"]);
1130    }
1131
1132    #[test]
1133    fn test_cycle_with_all_named_resources_creates_mutual_dependencies() {
1134        // Type A (resource `a`) -> Type B (resource `b`) -> Type C (resource `c`) -> Type A.
1135        // Each feature should depend on the others in the cycle.
1136        let doc = Document::from_yaml(indoc::indoc! {"
1137            openapi: 3.0.0
1138            info:
1139              title: Test
1140              version: 1.0.0
1141            components:
1142              schemas:
1143                A:
1144                  type: object
1145                  x-resourceId: a
1146                  properties:
1147                    b:
1148                      $ref: '#/components/schemas/B'
1149                B:
1150                  type: object
1151                  x-resourceId: b
1152                  properties:
1153                    c:
1154                      $ref: '#/components/schemas/C'
1155                C:
1156                  type: object
1157                  x-resourceId: c
1158                  properties:
1159                    a:
1160                      $ref: '#/components/schemas/A'
1161        "})
1162        .unwrap();
1163
1164        let arena = Arena::new();
1165        let spec = Spec::from_doc(&arena, &doc).unwrap();
1166        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1167        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1168
1169        let features = manifest.features();
1170
1171        // A transitively depends on B and C.
1172        assert_eq!(features["a"], ["b", "c"]);
1173
1174        // B transitively depends on A and C.
1175        assert_eq!(features["b"], ["a", "c"]);
1176
1177        // C transitively depends on A and B.
1178        assert_eq!(features["c"], ["a", "b"]);
1179
1180        // `default` should include all three.
1181        assert_eq!(features["default"], ["a", "b", "c"]);
1182    }
1183
1184    // MARK: Default feature
1185
1186    #[test]
1187    fn test_default_feature_includes_all_other_features() {
1188        let doc = Document::from_yaml(indoc::indoc! {"
1189            openapi: 3.0.0
1190            info:
1191              title: Test
1192              version: 1.0.0
1193            paths:
1194              /pets:
1195                get:
1196                  operationId: listPets
1197                  x-resource-name: pets
1198                  responses:
1199                    '200':
1200                      description: OK
1201            components:
1202              schemas:
1203                Customer:
1204                  type: object
1205                  x-resourceId: customer
1206                  properties:
1207                    id:
1208                      type: string
1209                Order:
1210                  type: object
1211                  x-resourceId: orders
1212                  properties:
1213                    id:
1214                      type: string
1215        "})
1216        .unwrap();
1217
1218        let arena = Arena::new();
1219        let spec = Spec::from_doc(&arena, &doc).unwrap();
1220        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1221        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1222
1223        // The `default` feature should include all other features,
1224        // but not itself.
1225        let features = manifest.features();
1226        assert_eq!(features["default"], ["customer", "orders", "pets"]);
1227    }
1228
1229    #[test]
1230    fn test_default_feature_includes_all_named_features() {
1231        let doc = Document::from_yaml(indoc::indoc! {"
1232            openapi: 3.0.0
1233            info:
1234              title: Test
1235              version: 1.0.0
1236            components:
1237              schemas:
1238                Customer:
1239                  type: object
1240                  x-resourceId: customer
1241                  properties:
1242                    id:
1243                      type: string
1244        "})
1245        .unwrap();
1246
1247        let arena = Arena::new();
1248        let spec = Spec::from_doc(&arena, &doc).unwrap();
1249        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1250        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1251
1252        // The `default` feature should include all named features.
1253        let features = manifest.features();
1254        assert_eq!(features["default"], ["customer"]);
1255    }
1256}