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::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        // Translate resource names from operations and schemas into
34        // Cargo feature names with dependencies.
35        let features = {
36            let mut deps_by_resource = BTreeMap::new();
37
38            // For each schema type with an explicitly declared resource name,
39            // use the resource as the feature name, and enable features
40            // for all its transitive dependencies.
41            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 each operation with an explicitly declared resource name,
56            // use the resource as the feature name, and enable features for
57            // all the types that are reachable from the operation.
58            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            // Build the `features` section of the manifest.
72            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                // `default` enables all other features.
89                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            // Ploidy generates Rust 2024-compatible code.
104            edition: Some(RustEdition::E2024),
105            dependencies: Some(BTreeMap::from_iter([
106                // `ploidy-util` is our only runtime dependency.
107                (
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/// A `Cargo.toml` manifest.
129#[derive(Clone, Debug)]
130pub struct CargoManifest(DocumentMut);
131
132impl CargoManifest {
133    /// Creates a Cargo manifest with the given package `name` and `version`.
134    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    /// Reads and parses an existing Cargo manifest from disk.
145    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    /// Parses a Cargo manifest from a TOML string.
151    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    /// Returns a view of the `package` section, or `None` if this is
165    /// a workspace or malformed manifest.
166    #[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    /// Returns the `features` table.
182    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    /// Applies a diff of changes to the manifest.
201    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/// A view of the `package` section of a Cargo manifest.
235#[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    /// Returns the package name.
244    pub fn name(&self) -> &'a str {
245        self.name.value
246    }
247
248    /// Parses and returns the package version.
249    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    /// Deserializes `package.metadata.ploidy` into a [`CodegenConfig`].
258    /// Returns `Ok(None)` if the section is absent, or `Err` if
259    /// it's present but malformed.
260    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/// A TOML value with source location information for diagnostics.
290#[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/// An error with source location information for diagnostics.
308#[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        // Equivalent to the generated implementation for
326        // `#[error(transparent)]`.
327        self.source.source()
328    }
329}
330
331/// The Rust edition that a package is compiled with.
332#[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/// A diff of changes to apply to a [`CargoManifest`].
349#[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/// An entry in the `dependencies` section of a Cargo manifest.
359#[derive(Clone, Debug)]
360pub enum Dependency {
361    Simple(Version),
362    Detailed(DependencyDetail),
363}
364
365impl Dependency {
366    /// Merges this dependency into an existing manifest entry. If the entry is
367    /// already a table, only the specified fields are updated; if it's
368    /// absent or a simple version string, it's replaced.
369    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/// A set of feature dependencies to merge into a `[features]` entry.
402#[derive(Clone, Debug)]
403pub struct FeatureDependencies(Vec<String>);
404
405impl FeatureDependencies {
406    /// Merges these feature dependencies into an existing manifest entry.
407    /// If the entry is already an array, all its existing dependencies
408    /// are preserved, and only new ones are added; if it's absent,
409    /// the array is created.
410    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    // MARK: Manifest and TOML types
463
464    #[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    // MARK: Feature collection
690
691    #[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_unnamed_schema_creates_no_features() {
749        let doc = Document::from_yaml(indoc::indoc! {"
750            openapi: 3.0.0
751            info:
752              title: Test
753              version: 1.0.0
754            components:
755              schemas:
756                Simple:
757                  type: object
758                  properties:
759                    id:
760                      type: string
761        "})
762        .unwrap();
763
764        let arena = Arena::new();
765        let spec = Spec::from_doc(&arena, &doc).unwrap();
766        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
767        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
768
769        let features = manifest.features();
770        let keys = features.keys().copied().collect_vec();
771        assert_matches!(&*keys, []);
772    }
773
774    // MARK: Schema feature dependencies
775
776    #[test]
777    fn test_schema_dependency_creates_feature_dependency() {
778        let doc = Document::from_yaml(indoc::indoc! {"
779            openapi: 3.0.0
780            info:
781              title: Test
782              version: 1.0.0
783            components:
784              schemas:
785                Customer:
786                  type: object
787                  x-resourceId: customer
788                  properties:
789                    billing:
790                      $ref: '#/components/schemas/BillingInfo'
791                BillingInfo:
792                  type: object
793                  x-resourceId: billing
794                  properties:
795                    card:
796                      type: string
797        "})
798        .unwrap();
799
800        let arena = Arena::new();
801        let spec = Spec::from_doc(&arena, &doc).unwrap();
802        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
803        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
804
805        // `Customer` depends on `BillingInfo`, so the `customer` feature
806        // should depend on `billing`.
807        let features = manifest.features();
808        assert_eq!(features["customer"], ["billing"]);
809    }
810
811    #[test]
812    fn test_transitive_schema_dependency_creates_feature_dependency() {
813        let doc = Document::from_yaml(indoc::indoc! {"
814            openapi: 3.0.0
815            info:
816              title: Test
817              version: 1.0.0
818            components:
819              schemas:
820                Order:
821                  type: object
822                  x-resourceId: orders
823                  properties:
824                    customer:
825                      $ref: '#/components/schemas/Customer'
826                Customer:
827                  type: object
828                  x-resourceId: customer
829                  properties:
830                    billing:
831                      $ref: '#/components/schemas/BillingInfo'
832                BillingInfo:
833                  type: object
834                  x-resourceId: billing
835                  properties:
836                    card:
837                      type: string
838        "})
839        .unwrap();
840
841        let arena = Arena::new();
842        let spec = Spec::from_doc(&arena, &doc).unwrap();
843        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
844        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
845
846        // `Order` -> `Customer` -> `BillingInfo`, so `orders` depends on
847        // both `customer` and `billing`.
848        let features = manifest.features();
849        assert_eq!(features["orders"], ["billing", "customer"]);
850    }
851
852    #[test]
853    fn test_unnamed_dependency_does_not_create_feature_dependency() {
854        let doc = Document::from_yaml(indoc::indoc! {"
855            openapi: 3.0.0
856            info:
857              title: Test
858              version: 1.0.0
859            components:
860              schemas:
861                Customer:
862                  type: object
863                  x-resourceId: customer
864                  properties:
865                    address:
866                      $ref: '#/components/schemas/Address'
867                Address:
868                  type: object
869                  properties:
870                    street:
871                      type: string
872        "})
873        .unwrap();
874
875        let arena = Arena::new();
876        let spec = Spec::from_doc(&arena, &doc).unwrap();
877        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
878        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
879
880        // `Customer` depends on `Address`, which doesn't have a resource.
881        // The `customer` feature should _not_ depend on `default`;
882        // that's handled via `cfg` attributes instead.
883        let features = manifest.features();
884        assert_matches!(&*features["customer"], &[]);
885    }
886
887    #[test]
888    fn test_feature_does_not_depend_on_itself() {
889        let doc = Document::from_yaml(indoc::indoc! {"
890            openapi: 3.0.0
891            info:
892              title: Test
893              version: 1.0.0
894            components:
895              schemas:
896                Node:
897                  type: object
898                  x-resourceId: nodes
899                  properties:
900                    children:
901                      type: array
902                      items:
903                        $ref: '#/components/schemas/Node'
904        "})
905        .unwrap();
906
907        let arena = Arena::new();
908        let spec = Spec::from_doc(&arena, &doc).unwrap();
909        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
910        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
911
912        // Self-referential schemas should not create self-dependencies.
913        let features = manifest.features();
914        assert_matches!(&*features["nodes"], []);
915    }
916
917    #[test]
918    fn test_schema_dependency_on_own_resource_does_not_create_feature_dependency() {
919        let doc = Document::from_yaml(indoc::indoc! {"
920            openapi: 3.0.0
921            info:
922              title: Test
923              version: 1.0.0
924            components:
925              schemas:
926                Default:
927                  type: object
928                  x-resourceId: default
929                  properties:
930                    child:
931                      $ref: '#/components/schemas/DefaultChild'
932                DefaultChild:
933                  type: object
934                  x-resourceId: default
935                  properties:
936                    id:
937                      type: string
938        "})
939        .unwrap();
940
941        let arena = Arena::new();
942        let spec = Spec::from_doc(&arena, &doc).unwrap();
943        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
944        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
945
946        let features = manifest.features();
947        assert_matches!(&*features["default2"], []);
948        assert_matches!(&*features["default"], ["default2"]);
949    }
950
951    // MARK: Operation feature dependencies
952
953    #[test]
954    fn test_operation_type_dependency_creates_feature_dependency() {
955        let doc = Document::from_yaml(indoc::indoc! {"
956            openapi: 3.0.0
957            info:
958              title: Test
959              version: 1.0.0
960            paths:
961              /orders:
962                get:
963                  operationId: listOrders
964                  x-resource-name: orders
965                  responses:
966                    '200':
967                      description: OK
968                      content:
969                        application/json:
970                          schema:
971                            type: array
972                            items:
973                              $ref: '#/components/schemas/Order'
974            components:
975              schemas:
976                Order:
977                  type: object
978                  properties:
979                    customer:
980                      $ref: '#/components/schemas/Customer'
981                Customer:
982                  type: object
983                  x-resourceId: customer
984                  properties:
985                    id:
986                      type: string
987        "})
988        .unwrap();
989
990        let arena = Arena::new();
991        let spec = Spec::from_doc(&arena, &doc).unwrap();
992        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
993        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
994
995        // `listOrders` returns `Order`, which references `Customer`, so
996        // `orders` should depend on `customer`.
997        let features = manifest.features();
998        assert_eq!(features["orders"], ["customer"]);
999    }
1000
1001    #[test]
1002    fn test_operation_with_unnamed_type_dependency_does_not_create_full_dependency() {
1003        let doc = Document::from_yaml(indoc::indoc! {"
1004            openapi: 3.0.0
1005            info:
1006              title: Test
1007              version: 1.0.0
1008            paths:
1009              /customers:
1010                get:
1011                  operationId: listCustomers
1012                  x-resource-name: customer
1013                  responses:
1014                    '200':
1015                      description: OK
1016                      content:
1017                        application/json:
1018                          schema:
1019                            type: array
1020                            items:
1021                              $ref: '#/components/schemas/Customer'
1022            components:
1023              schemas:
1024                Customer:
1025                  type: object
1026                  properties:
1027                    address:
1028                      $ref: '#/components/schemas/Address'
1029                Address:
1030                  type: object
1031                  properties:
1032                    street:
1033                      type: string
1034        "})
1035        .unwrap();
1036
1037        let arena = Arena::new();
1038        let spec = Spec::from_doc(&arena, &doc).unwrap();
1039        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1040        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1041
1042        // `listOrders` returns `Customer`, which references `Address`, but
1043        // `customer` should _not_ depend on `default`.
1044        let features = manifest.features();
1045        assert_matches!(&*features["customer"], []);
1046    }
1047
1048    #[test]
1049    fn test_operation_dependency_on_own_resource_does_not_create_feature_dependency() {
1050        let doc = Document::from_yaml(indoc::indoc! {"
1051            openapi: 3.0.0
1052            info:
1053              title: Test
1054              version: 1.0.0
1055            paths:
1056              /defaults:
1057                get:
1058                  operationId: listDefaults
1059                  x-resource-name: default
1060                  responses:
1061                    '200':
1062                      description: OK
1063                      content:
1064                        application/json:
1065                          schema:
1066                            type: array
1067                            items:
1068                              $ref: '#/components/schemas/Default'
1069            components:
1070              schemas:
1071                Default:
1072                  type: object
1073                  x-resourceId: default
1074                  properties:
1075                    id:
1076                      type: string
1077        "})
1078        .unwrap();
1079
1080        let arena = Arena::new();
1081        let spec = Spec::from_doc(&arena, &doc).unwrap();
1082        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1083        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1084
1085        let features = manifest.features();
1086        assert_matches!(&*features["default2"], []);
1087        assert_matches!(&*features["default"], ["default2"]);
1088    }
1089
1090    // MARK: Diamond dependencies
1091
1092    #[test]
1093    fn test_diamond_dependency_deduplicates_feature() {
1094        // A -> B, A -> C, B -> D, C -> D. All have resources.
1095        // A's feature should depend on B, C, and D; D should appear once.
1096        let doc = Document::from_yaml(indoc::indoc! {"
1097            openapi: 3.0.0
1098            info:
1099              title: Test
1100              version: 1.0.0
1101            components:
1102              schemas:
1103                A:
1104                  type: object
1105                  x-resourceId: a
1106                  properties:
1107                    b:
1108                      $ref: '#/components/schemas/B'
1109                    c:
1110                      $ref: '#/components/schemas/C'
1111                B:
1112                  type: object
1113                  x-resourceId: b
1114                  properties:
1115                    d:
1116                      $ref: '#/components/schemas/D'
1117                C:
1118                  type: object
1119                  x-resourceId: c
1120                  properties:
1121                    d:
1122                      $ref: '#/components/schemas/D'
1123                D:
1124                  type: object
1125                  x-resourceId: d
1126                  properties:
1127                    value:
1128                      type: string
1129        "})
1130        .unwrap();
1131
1132        let arena = Arena::new();
1133        let spec = Spec::from_doc(&arena, &doc).unwrap();
1134        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1135        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1136
1137        let features = manifest.features();
1138
1139        // `a` depends directly on `b` and `c`, and
1140        // transitively on `d` through both.
1141        assert_eq!(features["a"], ["b", "c", "d"]);
1142
1143        // `b` and `c` each depend on `d`.
1144        assert_eq!(features["b"], ["d"]);
1145        assert_eq!(features["c"], ["d"]);
1146
1147        // `d` has no dependencies.
1148        assert_matches!(&*features["d"], []);
1149    }
1150
1151    // MARK: Cycles with mixed resources
1152
1153    #[test]
1154    fn test_cycle_with_mixed_resources_does_not_create_feature_dependency() {
1155        // Type A (resource `a`) -> Type B (no resource) -> Type C (resource `c`) -> Type A.
1156        // Since B doesn't have a resource, we don't create a dependency on it;
1157        // that's handled via `#[cfg(...)]` attributes.
1158        let doc = Document::from_yaml(indoc::indoc! {"
1159            openapi: 3.0.0
1160            info:
1161              title: Test
1162              version: 1.0.0
1163            components:
1164              schemas:
1165                A:
1166                  type: object
1167                  x-resourceId: a
1168                  properties:
1169                    b:
1170                      $ref: '#/components/schemas/B'
1171                B:
1172                  type: object
1173                  properties:
1174                    c:
1175                      $ref: '#/components/schemas/C'
1176                C:
1177                  type: object
1178                  x-resourceId: c
1179                  properties:
1180                    a:
1181                      $ref: '#/components/schemas/A'
1182        "})
1183        .unwrap();
1184
1185        let arena = Arena::new();
1186        let spec = Spec::from_doc(&arena, &doc).unwrap();
1187        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1188        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1189
1190        let features = manifest.features();
1191
1192        // A depends on B (unnamed) and C. Since B is unnamed,
1193        // A only depends on C.
1194        assert_eq!(features["a"], ["c"]);
1195
1196        // C depends on A (which depends on B, unnamed). C only depends on A.
1197        assert_eq!(features["c"], ["a"]);
1198
1199        // `default` should include both named features.
1200        assert_eq!(features["default"], ["a", "c"]);
1201    }
1202
1203    #[test]
1204    fn test_cycle_with_all_named_resources_preserves_feature_members() {
1205        // Type A (resource `a`) -> Type B (resource `b`) -> Type C (resource `c`) -> Type A.
1206        // Each feature needs every other cycle member to compile.
1207        let doc = Document::from_yaml(indoc::indoc! {"
1208            openapi: 3.0.0
1209            info:
1210              title: Test
1211              version: 1.0.0
1212            components:
1213              schemas:
1214                A:
1215                  type: object
1216                  x-resourceId: a
1217                  properties:
1218                    b:
1219                      $ref: '#/components/schemas/B'
1220                B:
1221                  type: object
1222                  x-resourceId: b
1223                  properties:
1224                    c:
1225                      $ref: '#/components/schemas/C'
1226                C:
1227                  type: object
1228                  x-resourceId: c
1229                  properties:
1230                    a:
1231                      $ref: '#/components/schemas/A'
1232        "})
1233        .unwrap();
1234
1235        let arena = Arena::new();
1236        let spec = Spec::from_doc(&arena, &doc).unwrap();
1237        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1238        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1239
1240        let features = manifest.features();
1241
1242        // A transitively depends on B and C.
1243        assert_eq!(features["a"], ["b", "c"]);
1244
1245        // B transitively depends on A and C.
1246        assert_eq!(features["b"], ["a", "c"]);
1247
1248        // C transitively depends on A and B.
1249        assert_eq!(features["c"], ["a", "b"]);
1250
1251        // `default` should include all three.
1252        assert_eq!(features["default"], ["a", "b", "c"]);
1253    }
1254
1255    // MARK: Default feature
1256
1257    #[test]
1258    fn test_default_feature_includes_all_other_features() {
1259        let doc = Document::from_yaml(indoc::indoc! {"
1260            openapi: 3.0.0
1261            info:
1262              title: Test
1263              version: 1.0.0
1264            paths:
1265              /pets:
1266                get:
1267                  operationId: listPets
1268                  x-resource-name: pets
1269                  responses:
1270                    '200':
1271                      description: OK
1272            components:
1273              schemas:
1274                Customer:
1275                  type: object
1276                  x-resourceId: customer
1277                  properties:
1278                    id:
1279                      type: string
1280                Order:
1281                  type: object
1282                  x-resourceId: orders
1283                  properties:
1284                    id:
1285                      type: string
1286        "})
1287        .unwrap();
1288
1289        let arena = Arena::new();
1290        let spec = Spec::from_doc(&arena, &doc).unwrap();
1291        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1292        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1293
1294        // The `default` feature should include all other features,
1295        // but not itself.
1296        let features = manifest.features();
1297        assert_eq!(features["default"], ["customer", "orders", "pets"]);
1298    }
1299
1300    #[test]
1301    fn test_default_feature_includes_all_named_features() {
1302        let doc = Document::from_yaml(indoc::indoc! {"
1303            openapi: 3.0.0
1304            info:
1305              title: Test
1306              version: 1.0.0
1307            components:
1308              schemas:
1309                Customer:
1310                  type: object
1311                  x-resourceId: customer
1312                  properties:
1313                    id:
1314                      type: string
1315        "})
1316        .unwrap();
1317
1318        let arena = Arena::new();
1319        let spec = Spec::from_doc(&arena, &doc).unwrap();
1320        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1321        let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
1322
1323        // The `default` feature should include all named features.
1324        let features = manifest.features();
1325        assert_eq!(features["default"], ["customer"]);
1326    }
1327}