diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f60060e..d327f825 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,17 @@ jobs: - name: Test run: just test-examples + test-private: + name: Test private + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rui314/setup-mold@v1 + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@just + - name: Test + run: just test-private + test: name: Tests runs-on: ubuntu-latest diff --git a/.github/workflows/feature_powerset.yml b/.github/workflows/feature_powerset.yml index a0adb6cc..c188ae77 100644 --- a/.github/workflows/feature_powerset.yml +++ b/.github/workflows/feature_powerset.yml @@ -43,65 +43,105 @@ jobs: if: ${{ github.event_name == 'pull_request' && github.event.label.name == 'powerset_check' }} run: echo "should_run=true" >> "$GITHUB_OUTPUT" - powerset_test: - name: Powerset Tests + generate_powerset: needs: check_trigger if: ${{ needs.check_trigger.outputs.should_run1 == 'true' || needs.check_trigger.outputs.should_run2 == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest + outputs: + data: ${{ steps.build_data.outputs.data }} steps: - uses: actions/checkout@v4 - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-hack - - uses: taiki-e/install-action@nextest - # protoc is needed to build examples that have grpc enabled - - uses: taiki-e/install-action@protoc - - name: Test - run: cargo hack nextest run --no-fail-fast --feature-powerset --depth 3 --skip default --group-features jwt-ietf,jwt --group-features jwt-openid,jwt --group-features open-api,http --group-features email-smtp,email --group-features email-sendgrid,email --clean-per-run --log-group github-actions --exclude-no-default-features --exclude-all-features - - name: Check disk usage - run: df -h + - name: Random seed + id: random_seed + run: | + if [ -n "${{ github.event.number }}" ]; then + echo "seed=-r ${{ github.event.number }}" >> "$GITHUB_OUTPUT" + elif [ -n "${{ github.run_id }}" ]; then + echo "seed=-r ${{ github.run_id }}" >> "$GITHUB_OUTPUT" + else + echo "seed=" >> "$GITHUB_OUTPUT" + fi + - name: Build powerset data + id: build_data + run: | + cd private/powerset_matrix + echo "data=$(cargo run -- -s 40 -f json -c 50 ${{ steps.random_seed.outputs.seed }})" >> "$GITHUB_OUTPUT" - powerset_doc_test: - name: Powerset Doc tests - needs: check_trigger - if: ${{ needs.check_trigger.outputs.should_run1 == 'true' || needs.check_trigger.outputs.should_run2 == 'true' || github.event_name == 'workflow_dispatch' }} + powerset_test: + name: Powerset Tests + needs: generate_powerset runs-on: ubuntu-latest + strategy: + max-parallel: 10 + matrix: + index: ${{ fromJson(needs.generate_powerset.outputs.data).indexes }} + env: + features: ${{ join(fromJson(needs.generate_powerset.outputs.data).powersets[ matrix.index ], ' ') }} steps: - uses: actions/checkout@v4 - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-hack - # protoc is needed to build examples that have grpc enabled - - uses: taiki-e/install-action@protoc - - name: Doc test - run: cargo hack test --doc --no-fail-fast --feature-powerset --depth 3 --skip default --group-features jwt-ietf,jwt --group-features jwt-openid,jwt --group-features open-api,http --group-features email-smtp,email --group-features email-sendgrid,email --clean-per-run --log-group github-actions --exclude-no-default-features --exclude-all-features + - uses: taiki-e/install-action@nextest + - name: Test + run: | + features=($features) + i=0 + for feature_list in "${features[@]}"; do + echo "::group::$i: cargo nextest run --no-fail-fast --features $feature_list" + cargo nextest run --no-fail-fast --features "$feature_list" + echo "::endgroup::" + echo "::group::cargo test --doc --no-fail-fast --features $feature_list" + cargo test --doc --no-fail-fast --features "$feature_list" + cargo clean -p roadster + i=${expr $i + 1) + echo "::endgroup::" + done powerset_check: name: Powerset Check - needs: check_trigger - if: ${{ needs.check_trigger.outputs.should_run1 == 'true' || needs.check_trigger.outputs.should_run2 == 'true' || github.event_name == 'workflow_dispatch' }} + needs: generate_powerset runs-on: ubuntu-latest + strategy: + max-parallel: 10 + matrix: + index: ${{ fromJson(needs.generate_powerset.outputs.data).indexes }} + env: + features: ${{ join(fromJson(needs.generate_powerset.outputs.data).powersets[ matrix.index ], ' ') }} steps: - uses: actions/checkout@v4 - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-hack - # protoc is needed to build examples that have grpc enabled - - uses: taiki-e/install-action@protoc - name: Check - run: cargo hack check --feature-powerset --depth 3 --no-dev-deps --skip default --group-features jwt-ietf,jwt --group-features jwt-openid,jwt --group-features open-api,http --group-features email-smtp,email --group-features email-sendgrid,email --clean-per-run --log-group github-actions --exclude-no-default-features --exclude-all-features + run: | + features=($features) + i=0 + for feature_list in "${features[@]}"; do + echo "::group::$i: cargo check --no-dev-deps --features $feature_list" + cargo check --no-dev-deps --features "$feature_list" + cargo clean -p roadster + i=${expr $i + 1) + echo "::endgroup::" + done powerset_clippy: name: Powerset Clippy - needs: check_trigger - if: ${{ needs.check_trigger.outputs.should_run1 == 'true' || needs.check_trigger.outputs.should_run2 == 'true' || github.event_name == 'workflow_dispatch' }} + needs: generate_powerset runs-on: ubuntu-latest + strategy: + max-parallel: 10 + matrix: + index: ${{ fromJson(needs.generate_powerset.outputs.data).indexes }} + env: + features: ${{ join(fromJson(needs.generate_powerset.outputs.data).powersets[ matrix.index ], ' ') }} steps: - uses: actions/checkout@v4 - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-hack - # protoc is needed to build examples that have grpc enabled - - uses: taiki-e/install-action@protoc - name: Clippy - run: cargo hack clippy --all-targets --feature-powerset --depth 3 --skip default --group-features jwt-ietf,jwt --group-features jwt-openid,jwt --group-features open-api,http --group-features email-smtp,email --group-features email-sendgrid,email --clean-per-run --log-group github-actions --exclude-no-default-features --exclude-all-features -- -D warnings + run: | + features=($features) + i=0 + for feature_list in "${features[@]}"; do + echo "::group::$i: cargo clippy --features $feature_list -- -D warnings " + cargo clippy --features "$feature_list" -- -D warnings + cargo clean -p roadster + i=${expr $i + 1) + echo "::endgroup::" + done diff --git a/Cargo.toml b/Cargo.toml index 36c83e30..65cafcbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,12 +98,12 @@ rstest = { workspace = true, optional = true } # Others anyhow = { workspace = true } serde = { workspace = true } -serde_derive = "1.0.185" +serde_derive = { workspace = true } +serde_json = { workspace = true } serde_with = { version = "3.0.0", features = ["macros", "chrono_0_4"] } -strum = "0.26.0" -strum_macros = "0.26.0" -itertools = "0.13.0" -serde_json = "1.0.96" +strum = { workspace = true } +strum_macros = { workspace = true } +itertools = { workspace = true } toml = "0.8.0" url = { version = "2.5.0", features = ["serde"] } uuid = { version = "1.1.2", features = ["v4", "serde"] } @@ -113,10 +113,10 @@ chrono = { version = "0.4.34", features = ["serde"] } byte-unit = { version = "5.0.0", features = ["serde"] } convert_case = "0.6.0" const_format = "0.2.30" -typed-builder = "0.20.0" +typed-builder = { workspace = true } num-traits = "0.2.14" validator = { version = "0.18.0", features = ["derive"] } -thiserror = "1.0.38" +thiserror = { workspace = true } # Add latest version of `time` to resolve a build error on nightly # https://github.com/time-rs/time/issues/681 time = "0.3.36" @@ -131,7 +131,7 @@ mockall_double = "0.3.1" rstest = { workspace = true } [workspace] -members = [".", "examples/*", "book/examples/*"] +members = [".", "examples/*", "book/examples/*", "private/*"] [workspace.dependencies] # Tracing @@ -179,10 +179,19 @@ tokio = { version = "1.34.0", features = ["full"] } tokio-util = { version = "0.7.10" } anyhow = "1.0.86" serde = { version = "1.0.185", features = ["derive"] } +serde_derive = "1.0.185" +serde_json = "1.0.96" +strum = "0.26.0" +strum_macros = "0.26.0" cfg-if = "1.0.0" vergen = { version = "9.0.0" } vergen-gitcl = { version = "1.0.0" } reqwest = "0.12.8" +itertools = "0.13.0" +cargo-manifest = "0.15.0" +typed-builder = "0.20.0" +rand = "0.8.5" +thiserror = "1.0.49" [package.metadata.docs.rs] # Have docs.rs pass `--all-features` to ensure all features have their documentation built. diff --git a/book/src/comparisons/loco.md b/book/src/comparisons/loco.md index a2697831..00ab2b98 100644 --- a/book/src/comparisons/loco.md +++ b/book/src/comparisons/loco.md @@ -14,60 +14,60 @@ missing features are not planned but we'd be open to adding if there was enough *Last updated in Oct 2024.* -| Feature | Roadster | Loco | -|:------------------------------------------------------------------------------------------------------------------------------|:-----------|:-------------------------------------| -| Separate `cargo` CLI to help with generating code and other tasks | ❌ | ✅ | -| Custom CLI commands | ✅ | ✅ | -| HTTP APIs via Axum | ✅ | ✅ | -|  ↳ Default "ping" and "health" HTTP routes | ✅ | ✅ | -|   ↳ Default routes can be disabled via config | ✅ | ❌ | -|  ↳ Default middleware configured with sensible defaults | ✅ | ✅ | -|   ↳ Middleware configurations can be customized via config files | ✅ | ✅ | -|   ↳ Middleware execution order can be customized via config files | ✅ | ❌ | -| OpenAPI support | ✅ | ✅ | -|  ↳ built-in via [Aide](https://crates.io/crates/aide) | ✅ | ❌ | -|  ↳ 3rd party integration, e.g. [Utoipa](https://crates.io/crates/utoipa) | ✅ | ✅ | -|  ↳ OpenAPI docs explorer http route provided by default | ✅ | ❌ | -| GRPC API with [tonic](https://crates.io/crates/tonic) | ✅ | ❌ | -| Channels (websockets and/or http long-polling) | ❌ | ✅ | -| Support for running arbitrary long-running services | ✅ | ❌ | -| Health checks | ✅ | ✅ | -|  ↳ Run in "health" API route | ✅ | ✅ | -|  ↳ Run via CLI | ✅ | ❌ | -|  ↳ Run on app startup | ✅ | ❌ | -|  ↳ Consumer can provide custom checks | ✅ | ❌ | -| Health checks run in "health" route and on app startup | ✅ | ❌ | -| Custom app context / Axum state using Axum's [FromRef](https://docs.rs/axum-core/latest/axum_core/extract/trait.FromRef.html) | ✅ | ❌ | -| SQL DB via SeaORM | ✅ | ✅ | -|  ↳ Migrations for common DB schemas | ✅ (in lib) | ✅ (in starters) | -| Sample JWT Axum extractor | ✅ | ✅ | -|  ↳ Multiple JWT standards supported | ✅ | ❌ | -| Email | ✅ | ✅ | -|  ↳ via SMTP | ✅ | ✅ | -|  ↳ via [Sendgrid's Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send) | ✅ | ❌ | -| Storage abstraction | ❌* | ✅ | -| Cache abstraction | ❌* | ✅ | -| Background jobs | ✅ | ✅ | -|  ↳ via Sidekiq | ✅ | ✅ | -|  ↳ via Postgres | ❌ | ✅ | -|  ↳ via in-process threading with Tokio | ❌ | ✅ | -| Periodic jobs | ✅ | ✅ | -|  ↳ via Sidekiq | ✅ | ✅ | -|  ↳ via custom scheduler | ❌ | ✅ | -| Configuration via config files | ✅ | ✅ | -|  ↳ Toml | ✅ | ❌ | -|  ↳ Yaml | ✅ | ✅ | -| Config files can be split into multiple files | ✅ | ❌ | -| Config values can be overridden via env vars | ✅ | ✅ | -| Tracing via the [tracing](https://crates.io/crates/tracing) crate | ✅ | ✅ | -|  ↳ Trace/metric exporting via OpenTelemetry | ✅ | ❌ | -| [insta](https://crates.io/crates/insta) snapshot utilities | ✅ | ✅ | -| Data seeding and cleanup for tests | ❌* | ✅ (⚠️makes tests non-parallelizable) | -| Allows following any design pattern | ✅ | ❌ (MVC only) | -| Lifecycle hooks | ✅ | ✅ | -|  ↳ Customizable shutdown signal | ✅ | ❌ | -| HTML rendering | ✅ | ✅ | -|  ↳ Built-in | ❌ | ✅ | -|  ↳ via 3rd party integration, e.g. [Leptos](https://crates.io/crates/leptos) | ✅ | ❌ | -| Deployment config generation | ❌ | ✅ | -| Starter templates | ❌* | ✅ | +| Feature | Roadster | Loco | +|:------------------------------------------------------------------------------------------------------------------------------|:-----------|:--------------------------------------| +| Separate `cargo` CLI to help with generating code and other tasks | ❌ | ✅ | +| Custom CLI commands | ✅ | ✅ | +| HTTP APIs via Axum | ✅ | ✅ | +|  ↳ Default "ping" and "health" HTTP routes | ✅ | ✅ | +|   ↳ Default routes can be disabled via config | ✅ | ❌ | +|  ↳ Default middleware configured with sensible defaults | ✅ | ✅ | +|   ↳ Middleware configurations can be customized via config files | ✅ | ✅ | +|   ↳ Middleware execution order can be customized via config files | ✅ | ❌ | +| OpenAPI support | ✅ | ✅ | +|  ↳ built-in via [Aide](https://crates.io/crates/aide) | ✅ | ❌ | +|  ↳ 3rd party integration, e.g. [Utoipa](https://crates.io/crates/utoipa) | ✅ | ✅ | +|  ↳ OpenAPI docs explorer http route provided by default | ✅ | ❌ | +| GRPC API with [tonic](https://crates.io/crates/tonic) | ✅ | ❌ | +| Channels (websockets and/or http long-polling) | ❌ | ✅ | +| Support for running arbitrary long-running services | ✅ | ❌ | +| Health checks | ✅ | ✅ | +|  ↳ Run in "health" API route | ✅ | ✅ | +|  ↳ Run via CLI | ✅ | ❌ | +|  ↳ Run on app startup | ✅ | ❌ | +|  ↳ Consumer can provide custom checks | ✅ | ❌ | +| Health checks run in "health" route and on app startup | ✅ | ❌ | +| Custom app context / Axum state using Axum's [FromRef](https://docs.rs/axum-core/latest/axum_core/extract/trait.FromRef.html) | ✅ | ❌ | +| SQL DB via SeaORM | ✅ | ✅ | +|  ↳ Migrations for common DB schemas | ✅ (in lib) | ✅ (in starters) | +| Sample JWT Axum extractor | ✅ | ✅ | +|  ↳ Multiple JWT standards supported | ✅ | ❌ | +| Email | ✅ | ✅ | +|  ↳ via SMTP | ✅ | ✅ | +|  ↳ via [Sendgrid's Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send) | ✅ | ❌ | +| Storage abstraction | ❌* | ✅ | +| Cache abstraction | ❌* | ✅ | +| Background jobs | ✅ | ✅ | +|  ↳ via Sidekiq | ✅ | ✅ | +|  ↳ via Postgres | ❌ | ✅ | +|  ↳ via in-process threading with Tokio | ❌ | ✅ | +| Periodic jobs | ✅ | ✅ | +|  ↳ via Sidekiq | ✅ | ✅ | +|  ↳ via custom scheduler | ❌ | ✅ | +| Configuration via config files | ✅ | ✅ | +|  ↳ Toml | ✅ | ❌ | +|  ↳ Yaml | ✅ | ✅ | +| Config files can be split into multiple files | ✅ | ❌ | +| Config values can be overridden via env vars | ✅ | ✅ | +| Tracing via the [tracing](https://crates.io/crates/tracing) crate | ✅ | ✅ | +|  ↳ Trace/metric exporting via OpenTelemetry | ✅ | ❌ | +| [insta](https://crates.io/crates/insta) snapshot utilities | ✅ | ✅ | +| Data seeding and cleanup for tests | ❌* | ✅ (⚠️ makes tests non-parallelizable) | +| Allows following any design pattern | ✅ | ❌ (MVC only) | +| Lifecycle hooks | ✅ | ✅ | +|  ↳ Customizable shutdown signal | ✅ | ❌ | +| HTML rendering | ✅ | ✅ | +|  ↳ Built-in | ❌ | ✅ | +|  ↳ via 3rd party integration, e.g. [Leptos](https://crates.io/crates/leptos) | ✅ | ❌ | +| Deployment config generation | ❌ | ✅ | +| Starter templates | ❌* | ✅ | diff --git a/justfile b/justfile index 108f3e97..594c08be 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,9 @@ test-doc: test-examples: for dir in ./examples/*/; do cd $dir && pwd && cargo test --all-features --no-fail-fast && cd ../.. && pwd; done +test-private: + for dir in ./private/*/; do cd $dir && pwd && cargo test --all-features --no-fail-fast && cd ../.. && pwd; done + # Run all of our unit tests. test-unit: test test-doc diff --git a/private/README.md b/private/README.md new file mode 100644 index 00000000..fd3b412b --- /dev/null +++ b/private/README.md @@ -0,0 +1,2 @@ +Things in here are not intended to be published or otherwise used externally. For example `powerset_matrix` is a utility +for Roadster's GitHub CI workflows. diff --git a/private/powerset_matrix/Cargo.toml b/private/powerset_matrix/Cargo.toml new file mode 100644 index 00000000..91e0bad8 --- /dev/null +++ b/private/powerset_matrix/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "powerset_matrix" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cargo-manifest = { workspace = true } +itertools = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +typed-builder = { workspace = true } +rand = { workspace = true, features = ["std_rng"] } +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } + +[dev-dependencies] +roadster = { path = "../..", default-features = false, features = ["testing"] } +rstest = { workspace = true } +insta = { workspace = true } diff --git a/private/powerset_matrix/src/cli.rs b/private/powerset_matrix/src/cli.rs new file mode 100644 index 00000000..f6d25249 --- /dev/null +++ b/private/powerset_matrix/src/cli.rs @@ -0,0 +1,46 @@ +use clap::Parser; +use strum_macros::{EnumString, IntoStaticStr}; +use typed_builder::TypedBuilder; + +#[derive(Debug, Default, Parser, TypedBuilder)] +#[command(version, about)] +#[non_exhaustive] +pub struct Cli { + /// The output format + #[clap(short, long, default_value = "debug")] + #[builder(default)] + pub format: Format, + + /// The size of each powerset group. If provided, the final powerset will be split into + /// multiple groups of the given size. If not provided, the set will be provided in a single + /// group. + #[clap(short = 's', long)] + #[builder(default, setter(strip_option))] + pub group_size: Option, + + /// The maximum "depth" of the "limited" powerset. Each subset of the "limited" powerset + /// will be at most this big. + #[clap(short = 'd', long = "depth", default_value_t = 3)] + pub limited_depth: usize, + + /// The number of subsets to return -- at random -- from the full powerset. + #[builder(default, setter(strip_option))] + #[clap(short = 'c', long)] + pub random_count: Option, + + /// The value to use to seed the PRNG used to pick from the full powerset. + #[builder(default, setter(strip_option))] + #[clap(short = 'r', long)] + pub random_seed: Option, +} + +/// The output format +#[derive(Debug, Default, Clone, Eq, PartialEq, EnumString, IntoStaticStr, clap::ValueEnum)] +#[strum(serialize_all = "kebab-case")] +#[non_exhaustive] +pub enum Format { + #[default] + Debug, + Json, + JsonPretty, +} diff --git a/private/powerset_matrix/src/lib.rs b/private/powerset_matrix/src/lib.rs new file mode 100644 index 00000000..c858a928 --- /dev/null +++ b/private/powerset_matrix/src/lib.rs @@ -0,0 +1,151 @@ +#![allow(clippy::disallowed_macros)] + +use crate::cli::Cli; +use anyhow::anyhow; +use cargo_manifest::Manifest; +use itertools::Itertools; +use rand::prelude::{IteratorRandom, StdRng}; +use rand::SeedableRng; +use std::collections::BTreeSet; + +pub mod cli; + +pub fn powerset( + cli: &Cli, + manifest: Manifest, + feature_groups: Vec>, + features_to_skip: Vec, +) -> anyhow::Result>> { + let features = manifest + .features + .into_iter() + .flatten() + .map(|f| f.0) + .collect_vec(); + + powerset_impl(cli, features, feature_groups, features_to_skip) +} + +fn powerset_impl( + cli: &Cli, + features: Vec, + feature_groups: Vec>, + features_to_skip: Vec, +) -> anyhow::Result>> { + { + let group_members = feature_groups + .iter() + .flat_map(|g| g.iter()) + .map(|f| f.to_string()) + .collect_vec(); + + for feature in group_members { + if !features.contains(&feature) { + return Err(anyhow!( + "Group feature {feature} is not a valid feature name" + )); + } + } + } + + let features = features + .into_iter() + .filter(|f| !features_to_skip.contains(f)) + .collect_vec(); + + let features = features + .into_iter() + .map(|n| { + let group = feature_groups.iter().find(|g| g.contains(&n)); + if let Some(group) = group { + group.iter().join(",") + } else { + n + } + }) + .unique() + .collect_vec(); + + let sets = limited(cli, features.clone())? + .into_iter() + .chain(random(cli, features)?) + .collect_vec(); + + Ok(sets) +} + +fn limited(cli: &Cli, features: Vec) -> anyhow::Result>> { + let ps = features.into_iter().powerset().collect_vec(); + + let ps = ps + .into_iter() + // Reduce the size of the powerset by + // - Skipping sets with only one item -- we already check each feature on every PR + // - Skipping sets with more than `cli.limited_depth` items + .filter(|s| s.len() > 1 && s.len() <= cli.limited_depth) + .collect_vec(); + + Ok(ps) +} + +fn random(cli: &Cli, features: Vec) -> anyhow::Result>> { + let count = if let Some(count) = cli.random_count { + count + } else { + return Ok(vec![]); + }; + + let seed = if let Some(seed) = cli.random_seed { + seed + } else { + rand::random() + }; + eprintln!("Using seed {seed}"); + let mut rng = StdRng::seed_from_u64(seed); + + let ps = features + .into_iter() + .powerset() + .filter(|s| s.len() > cli.limited_depth) + .choose_multiple(&mut rng, count); + + Ok(ps) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + use roadster::testing::snapshot::TestCase; + use rstest::{fixture, rstest}; + + #[fixture] + fn case() -> TestCase { + Default::default() + } + + #[rstest] + #[case(Cli::default(), &["a"], vec![], &[])] + #[case(Cli::builder().limited_depth(5).build(), &["a", "b", "c"], vec![], &[])] + #[case(Cli::builder().limited_depth(5).build(), &["a", "b", "c"], vec![vec!["a", "b"]], &[])] + #[case(Cli::builder().limited_depth(5).build(), &["a", "b", "c"], vec![], &["c"])] + #[case(Cli::builder().limited_depth(2).random_seed(1).random_count(1).build(), &["a", "b", "c", "d", "e", "f"], vec![], &[])] + fn powerset_impl( + _case: TestCase, + #[case] cli: Cli, + #[case] features: &[&str], + #[case] groups: Vec>, + #[case] skip: &[&str], + ) { + let features = features.iter().map(|s| s.to_string()).collect_vec(); + let groups = groups + .iter() + .map(|g| g.iter().map(|f| f.to_string()).collect()) + .collect_vec(); + let skip = skip.iter().map(|s| s.to_string()).collect_vec(); + + let powerset = super::powerset_impl(&cli, features, groups, skip); + + assert_debug_snapshot!(powerset); + } +} diff --git a/private/powerset_matrix/src/main.rs b/private/powerset_matrix/src/main.rs new file mode 100644 index 00000000..8467e72d --- /dev/null +++ b/private/powerset_matrix/src/main.rs @@ -0,0 +1,77 @@ +#![allow(clippy::disallowed_macros)] + +use cargo_manifest::Manifest; +use clap::Parser; +use itertools::Itertools; +use powerset_matrix::cli::{Cli, Format}; +use powerset_matrix::powerset; +use serde_derive::Serialize; +use std::collections::BTreeSet; +use typed_builder::TypedBuilder; + +#[derive(Debug, Serialize, TypedBuilder)] +struct Output { + indexes: Vec, + powersets: Vec>, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let manifest = Manifest::from_path("../../Cargo.toml")?; + + // Features to skip -- helps reduce the size of the powerset + let skip = vec!["default"] + .into_iter() + .map(|f| f.to_string()) + .collect_vec(); + + // Features to group together -- helps reduce the size of the powerset + let groups: Vec> = vec![ + vec!["email", "email-smtp"], + vec!["email", "email-sendgrid"], + vec!["jwt", "jwt-ietf"], + vec!["jwt", "jwt-openid"], + vec!["open-api", "http"], + ] + .into_iter() + .map(|v| v.into_iter().map(|s| s.to_string()).collect()) + .collect_vec(); + + let powerset = powerset(&cli, manifest, groups, skip)?; + let total_powersets = powerset.len(); + let powerset = powerset.into_iter().map(|v| v.join(",")).collect_vec(); + + let powersets = if let Some(group_size) = cli.group_size { + let groups = powerset + .chunks(group_size) + .map(|v| v.to_vec()) + .collect_vec(); + groups + } else { + vec![powerset] + }; + let indexes = (0..powersets.len()).collect_vec(); + let output = Output::builder() + .indexes(indexes) + .powersets(powersets) + .build(); + + match cli.format { + Format::Debug => { + println!("{output:?}"); + } + Format::Json => { + println!("{}", serde_json::to_string(&output)?); + } + Format::JsonPretty => { + println!("{}", serde_json::to_string_pretty(&output)?); + } + _ => { + unimplemented!() + } + } + + eprintln!("Total powersets: {}", total_powersets); + Ok(()) +} diff --git a/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_1.snap b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_1.snap new file mode 100644 index 00000000..7bc537e6 --- /dev/null +++ b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_1.snap @@ -0,0 +1,7 @@ +--- +source: private/powerset_matrix/src/lib.rs +expression: powerset +--- +Ok( + [], +) diff --git a/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_2.snap b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_2.snap new file mode 100644 index 00000000..31ff4587 --- /dev/null +++ b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_2.snap @@ -0,0 +1,25 @@ +--- +source: private/powerset_matrix/src/lib.rs +expression: powerset +--- +Ok( + [ + [ + "a", + "b", + ], + [ + "a", + "c", + ], + [ + "b", + "c", + ], + [ + "a", + "b", + "c", + ], + ], +) diff --git a/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_3.snap b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_3.snap new file mode 100644 index 00000000..0090f400 --- /dev/null +++ b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_3.snap @@ -0,0 +1,12 @@ +--- +source: private/powerset_matrix/src/lib.rs +expression: powerset +--- +Ok( + [ + [ + "a,b", + "c", + ], + ], +) diff --git a/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_4.snap b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_4.snap new file mode 100644 index 00000000..41eb2e19 --- /dev/null +++ b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_4.snap @@ -0,0 +1,12 @@ +--- +source: private/powerset_matrix/src/lib.rs +expression: powerset +--- +Ok( + [ + [ + "a", + "b", + ], + ], +) diff --git a/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_5.snap b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_5.snap new file mode 100644 index 00000000..418e9be6 --- /dev/null +++ b/private/powerset_matrix/src/snapshots/powerset_matrix__tests__powerset_impl@case_5.snap @@ -0,0 +1,73 @@ +--- +source: private/powerset_matrix/src/lib.rs +expression: powerset +--- +Ok( + [ + [ + "a", + "b", + ], + [ + "a", + "c", + ], + [ + "a", + "d", + ], + [ + "a", + "e", + ], + [ + "a", + "f", + ], + [ + "b", + "c", + ], + [ + "b", + "d", + ], + [ + "b", + "e", + ], + [ + "b", + "f", + ], + [ + "c", + "d", + ], + [ + "c", + "e", + ], + [ + "c", + "f", + ], + [ + "d", + "e", + ], + [ + "d", + "f", + ], + [ + "e", + "f", + ], + [ + "a", + "b", + "e", + ], + ], +) diff --git a/src/config/mod.rs b/src/config/mod.rs index d909534f..60d0c21d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -329,7 +329,8 @@ pub struct App { feature = "jwt", feature = "jwt-ietf", feature = "otel", - feature = "email-smtp" + feature = "email-smtp", + feature = "email-sendgrid" ))] mod tests { use super::*;