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