diff --git a/.claude/settings.json b/.claude/settings.json index 869a5cc..734f380 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,7 +8,7 @@ "Bash(cargo check:*)", "Bash(cargo test:*)", "Bash(cargo doc:*)", - "Bash(cargo run --example:*)", + "Bash(cargo run:*)", "Bash(cargo tree:*)", "Bash(cargo metadata:*)", "Bash(cargo deny check:*)", diff --git a/.github/scripts/check-file-coverage.sh b/.github/scripts/check-file-coverage.sh index 95da178..df41e23 100755 --- a/.github/scripts/check-file-coverage.sh +++ b/.github/scripts/check-file-coverage.sh @@ -4,10 +4,14 @@ set -euo pipefail minimum="${1:-90}" report="${2:-coverage.json}" workspace_root="$(pwd -P)/" -source_root="${workspace_root}src/" +# Every crate lives under `crates//src/`, so one prefix covers the +# whole workspace. Vendored submodules and `worktrees/` sit outside it and are +# excluded by the same test. +source_root="${workspace_root}crates/" cargo llvm-cov \ --locked \ + --workspace \ --all-targets \ --all-features \ --json \ @@ -23,7 +27,7 @@ covered_files="$(jq --arg source_root "$source_root" ' ' "$report")" if [[ "$covered_files" -eq 0 ]]; then - echo "coverage report contains no files with executable lines under src/" >&2 + echo "coverage report contains no files with executable lines under crates/" >&2 exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c30655..ba8c2fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ permissions: contents: read env: - # Lint levels live in `[lints]` in Cargo.toml so local and CI runs agree; - # don't add a blanket RUSTFLAGS here. + # Lint levels live in `[workspace.lints]` in the root Cargo.toml so local and + # CI runs agree; don't add a blanket RUSTFLAGS here. CARGO_TERM_COLOR: always jobs: @@ -26,13 +26,15 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov - uses: Swatinem/rust-cache@v2 @@ -51,12 +53,41 @@ jobs: - name: Test default features run: cargo test + # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` + # promises `cargo run -p template --example basic` works, and a compiled + # example can still fail on its first line. + - name: Run the bundled example + run: cargo run -p template --example basic + + # `crates/template-bus` exists so a host can name the payload types + # without compiling the module. That promise is invisible in a diff, + # because a forbidden dependency arrives transitively through a feature + # someone enabled one crate away — so it is asserted rather than + # documented. + # + # The FORWARD form is required. `cargo tree -i -p template-bus` + # discards the `-p` scope, prints the whole-workspace inverse tree, and + # exits 0 looking clean even when this crate is the one at fault. + - name: Assert the contract crate stays transport-free + run: | + set -euo pipefail + forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ + | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" + if [ -n "$forbidden" ]; then + echo "template-bus pulled in a dependency its manifest forbids:" >&2 + echo "$forbidden" >&2 + echo >&2 + echo "The contract is what a host compiles against. It must stay free" >&2 + echo "of transports, async runtimes, HTTP clients and native libraries." >&2 + exit 1 + fi + - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json - name: Upload coverage report if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-json path: coverage.json @@ -69,7 +100,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -87,16 +118,19 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive + # `rust-version` is inherited from `[workspace.package]`, so every member + # reports the same value. Read it off the package the module ships as + # rather than off `packages[0]`, whose order cargo does not promise. - name: Read rust-version from Cargo.toml id: msrv run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[0].rust_version')" + | jq -r '.packages[] | select(.name == "template") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then - echo "package.rust-version is not set in Cargo.toml" >&2 + echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 fi echo "version=$msrv" >> "$GITHUB_OUTPUT" @@ -117,7 +151,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d892cb..4acf379 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ concurrency: permissions: contents: write +env: + # The workspace member that ships as the loadable module. Its package name is + # the artifact name and the library name; `crates/template-bus` rides along on + # the same inherited version and is not packaged separately. + RELEASE_PACKAGE: template + jobs: prepare: name: Prepare release @@ -33,13 +39,15 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov - uses: Swatinem/rust-cache@v2 @@ -70,8 +78,10 @@ jobs: set -euo pipefail metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r '.packages[0].name' <<< "$metadata")" - current_version="$(jq -r '.packages[0].version' <<< "$metadata")" + crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ + '.packages[] | select(.name == $name) | .name' <<< "$metadata")" + current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ + '.packages[] | select(.name == $name) | .version' <<< "$metadata")" if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then echo "Could not resolve the crate name" >&2 exit 1 @@ -114,7 +124,7 @@ jobs: fi tagged_version="$( git show "${tag}:Cargo.toml" \ - | sed -n 's/^version = "\([^"]*\)"/\1/p' \ + | sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)"/\1/p' \ | head -n 1 )" if [[ "$tagged_version" != "$current_version" ]]; then @@ -140,8 +150,20 @@ jobs: NEXT_VERSION: ${{ steps.version.outputs.next_version }} run: | set -euo pipefail - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" + # One version for the whole workspace: every member inherits it with + # `version.workspace = true`, so this is the only edit needed. + perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml + # `--workspace` re-resolves the local packages only, which is what a + # version bump changes. `-p --precise` cannot express "and the + # other member moved too". + cargo update --workspace + released="$(cargo metadata --format-version 1 --no-deps \ + | jq -r --arg name "$CRATE_NAME" \ + '.packages[] | select(.name == $name) | .version')" + if [[ "$released" != "$NEXT_VERSION" ]]; then + echo "version bump did not take: expected ${NEXT_VERSION}, got ${released}" >&2 + exit 1 + fi - name: Commit version bump and tag if: ${{ inputs.bump != 'current' }} @@ -203,7 +225,7 @@ jobs: with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -222,7 +244,7 @@ jobs: fi - name: Build installable module - run: cargo build --locked --release --lib + run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - name: Verify Unix module through TinyBus loader if: ${{ runner.os != 'Windows' }} @@ -237,7 +259,7 @@ jobs: macOS) module="target/release/lib${library_name}.dylib" ;; *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; esac - cargo run --locked --example verify_module -- "$module" + cargo run --locked --package template --example verify_module -- "$module" - name: Verify Windows module through TinyBus loader if: ${{ runner.os == 'Windows' }} @@ -248,7 +270,7 @@ jobs: $ErrorActionPreference = 'Stop' $libraryName = $env:CRATE_NAME.Replace('-', '_') $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'rust-template-module-verify' + $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' New-Item -ItemType Directory -Force $verifyRoot | Out-Null $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() @@ -278,7 +300,7 @@ jobs: $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --example verify_module -- $verifiedModule + cargo run --locked --package template --example verify_module -- $verifiedModule - name: Assemble Unix module package if: ${{ runner.os != 'Windows' }} @@ -353,7 +375,7 @@ jobs: - name: Upload Unix package if: ${{ runner.os != 'Windows' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} path: ${{ steps.unix_package.outputs.archive }} @@ -361,7 +383,7 @@ jobs: - name: Upload Windows package if: ${{ runner.os == 'Windows' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} path: ${{ steps.windows_package.outputs.archive }} @@ -414,7 +436,7 @@ jobs: with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -430,7 +452,7 @@ jobs: fi - name: Build installable module - run: cargo build --locked --release --lib + run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - name: Verify module through TinyBus loader env: @@ -441,7 +463,7 @@ jobs: verify_root="/opt/${CRATE_NAME}-module-verify" install -d -m 700 "$verify_root" install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --example verify_module -- \ + cargo run --locked --package template --example verify_module -- \ "$verify_root/lib${library_name}.so" - name: Assemble distribution module package @@ -475,7 +497,7 @@ jobs: echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - name: Upload distribution package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} path: ${{ steps.package.outputs.archive }} @@ -493,13 +515,13 @@ jobs: with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: '*' path: release-assets @@ -565,5 +587,5 @@ jobs: cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ --package tinybus --all-features --example github_module_host -- \ "$release_url" "$archive" "$sha256" - cargo run --locked --example verify_github_release -- \ + cargo run --locked --package template --example verify_github_release -- \ "$release_url" "$archive" "$sha256" diff --git a/AGENTS.md b/AGENTS.md index 8d6c047..ee8fdfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,34 +12,57 @@ Delete guidance that no longer applies rather than leaving it to rot. Do this once, in a single commit, before writing feature code: -- [ ] Set `name`, `description`, `repository`, `keywords`, and `categories` in - `Cargo.toml`. -- [ ] Rename the crate references in `README.md`, `src/lib.rs`, `examples/`, - and `tests/` (search for `rust_template` and `rust-template`). -- [ ] Replace the placeholder `greeting` module with the first real feature - area, keeping the `mod.rs` / `types.rs` / `test.rs` layout. +- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate + names, and update `name` in each manifest plus the `template-bus` entry in + the root `[workspace.dependencies]`. +- [ ] Set `description`, `keywords`, and `categories` in each manifest, and + `repository` in the root `[workspace.package]`. +- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, + `crates/template/examples/`, and `crates/template/tests/` (search for + `template` and `template_bus`). +- [ ] Replace the placeholder `greeting` module in both crates with the first + real feature area — payload types in the contract crate, behavior in the + module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. - [ ] Confirm `license` and `LICENSE` match the project's intended license. - [ ] Update the security contact in `SECURITY.md`. +- [ ] Rename the TinyBus interface, object path, and member constants in + `crates/template-bus/src/names/`, and the matching `provides` / `methods` + declarations in `crates/template/src/tinybus_module/`, while keeping + `vendor/tinybus` pinned. +- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new + contract. - [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rename the TinyBus interface, object path, and declared methods in - `src/tinybus_module/` while keeping `vendor/tinybus` pinned. -- [ ] Rewrite the "Project Structure" section below to describe this crate. +- [ ] Rewrite the "Project Structure" section below to describe this workspace. ## Project Structure -This is a Rust 2024 library crate rooted at `Cargo.toml`. +This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every +crate lives under `crates/`, one directory per package, each directory named for +the package it holds. There is no root package: the crate that ships as the +loadable module is `crates/template`, the same as any other member. ```text -src/ -├── lib.rs # crate docs + the entire public re-export surface -├── error/mod.rs # crate-wide `Error` and `Result` -├── tinybus_module/ # TinyBus interface, ABI exports, and integration tests -└── / # one directory per feature area - ├── mod.rs # module docs, wiring, smallest useful public API - ├── types.rs # substantial type definitions - └── test.rs # module-local unit tests -tests/ # integration tests against the public API only -examples/ # runnable, compiled-in-CI usage examples +Cargo.toml # virtual workspace: members, [workspace.package], + # [workspace.dependencies], [workspace.lints] +crates/ +├── template-bus/ # the wire contract: what crosses the bus, nothing else +│ ├── README.md # why the contract is its own crate +│ └── src/ +│ ├── lib.rs # crate docs + the entire public re-export surface +│ ├── names/ # interface, object path, one constant per member +│ ├── version/ # contract version and the host bind rule +│ └── / # one directory per payload family +└── template/ # the module: behavior, adapter, and the cdylib + ├── src/ + │ ├── lib.rs # crate docs + public surface, re-exporting the contract + │ ├── error/mod.rs # crate-wide `Error` and `Result` + │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests + │ └── / # one directory per feature area + │ ├── mod.rs # module docs, wiring, smallest useful public API + │ ├── types.rs # substantial type definitions + │ └── test.rs # module-local unit tests + ├── tests/ # integration tests against the public API only + └── examples/ # runnable, compiled-in-CI usage examples vendor/tinybus/ # pinned TinyBus host types and module SDK docs/ ├── specs/ # behavior and architecture specifications @@ -47,9 +70,35 @@ docs/ └── adr/ # immutable architecture decision records ``` -Each feature area belongs in a focused module directory under `src/`. A module -root explains the module, wires its pieces together, and exposes the smallest -useful API. Move substantial type definitions into `types.rs` and put +### The two-crate split + +`crates/template-bus` holds every type that crosses the bus and the names of the +members that carry them. It has no transport, no runtime, and no behavior, and +CI asserts it stays that way. A host that only makes calls depends on it alone. + +`crates/template` depends on it and re-exports all of it, so +`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type +rather than structural twins. That direction is load-bearing: a parallel set of +payload types for hosts would mean a conversion at every call site that nothing +checks. + +The rule for deciding where something goes: a payload type describes what a +frame carries and belongs in the contract; anything that answers a frame, holds +a connection, or touches an engine belongs in the module crate. + +Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up +by existing. Inherit `version`, `edition`, `rust-version`, `license`, and +`repository` from `[workspace.package]`, take shared dependencies from +`[workspace.dependencies]`, and opt into the shared lint set with: + +```toml +[lints] +workspace = true +``` + +Each feature area belongs in a focused module directory under a crate's `src/`. +A module root explains the module, wires its pieces together, and exposes the +smallest useful API. Move substantial type definitions into `types.rs` and put module-local unit tests in a dedicated `test.rs`, wired from the bottom of the module root with: @@ -63,9 +112,10 @@ let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of missing module. Prefer many small modules that each do one thing well over few broad ones. -Keep public exports centralized in `src/lib.rs` so downstream users have one -predictable surface. Put shared error variants in `src/error/mod.rs` and return -the crate-wide `Result` from fallible public APIs. +Keep public exports centralized in each crate's `src/lib.rs` so downstream users +have one predictable surface. Put shared error variants in +`crates/template/src/error/mod.rs` and return the crate-wide `Result` from +fallible public APIs. ## Build And Test @@ -83,7 +133,8 @@ Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. -- `cargo run --example basic` — run the bundled example. +- `cargo test -p template-bus` — run one crate's suite. +- `cargo run -p template --example basic` — run the bundled example. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. - `cargo test --doc` — run doctests alone when editing documentation examples. @@ -105,13 +156,14 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around `impl Into` at boundaries; return owned, concrete types. - Keep the public surface minimal: default to private, and export deliberately from `src/lib.rs`. -- `unsafe` is forbidden crate-wide by the lint configuration in `Cargo.toml`. - If a project genuinely needs it, relax the lint in its own commit and document - every invariant with a `// SAFETY:` comment. +- `unsafe` is forbidden workspace-wide by `[workspace.lints]` in the root + `Cargo.toml`. If a project genuinely needs it, relax the lint in its own + commit and document every invariant with a `// SAFETY:` comment. ### Errors -- One crate-wide `Error` enum in `src/error/mod.rs`, built with `thiserror`. +- One crate-wide `Error` enum per crate, in `src/error/mod.rs`, built with + `thiserror`. - Fallible public functions return `Result`, the crate alias. - Add a specific variant instead of stuffing context into a string; error messages are lowercase, without trailing punctuation. @@ -131,12 +183,16 @@ add one: - enable only the features you need, with `default-features = false` when that meaningfully trims the tree; - gate anything optional behind a Cargo feature, documented in `Cargo.toml`; +- declare it once in the root `[workspace.dependencies]` when more than one + crate needs it, and take it with `{ workspace = true }`; +- never add one to `crates/template-bus` that pulls in a transport, an async + runtime, an HTTP client, or a native library — CI fails the build if you do; - leave a comment above the entry explaining *why* the crate is needed and what uses it — see the existing entries for the expected tone; - prefer well-maintained crates with a compatible license. -Keep `Cargo.lock` committed; this crate ships a lockfile so CI and releases are -reproducible. +Keep `Cargo.lock` committed; this workspace ships a single lockfile so CI and +releases are reproducible. ### Vendored dependencies @@ -155,10 +211,13 @@ new module capability requires more. ## Testing -- Module-local unit tests live in `src//test.rs` and may touch private - items. -- Integration tests live in `tests/` and exercise only the public API — they are - the regression suite for the crate's contract. +- Module-local unit tests live in `crates//src//test.rs` and may + touch private items. +- Integration tests live in `crates//tests/` and exercise only the public + API — they are the regression suite for the crate's contract. +- Payload types pin their serde representation in a unit test. That + representation is the wire form: a host and a module that disagree about a + field name fail at runtime with a decode error. - Use descriptive, behavioral test names: `rejects_an_empty_name`, not `test_greet_2`. - Cover the failure paths, not just the happy path. Every new error variant @@ -183,8 +242,9 @@ Write documentation for the reader who has never seen the code. treats as an error. - Start every `mod.rs` and `test.rs` with a concise module-level `//!` description. -- `src/lib.rs` carries the crate-level overview: what the crate does, the - primary entry points, and a short runnable example. +- Each crate's `src/lib.rs` carries its crate-level overview: what the crate + does, the primary entry points, and a short runnable example. It should also + say what the crate deliberately does *not* hold, and why. - Prefer concrete examples over vague description. Doc examples are compiled and run by `cargo test`, so they cannot drift. - Complex modules must include a module-level `README.md` covering their design, @@ -234,14 +294,16 @@ Releases run from `.github/workflows/release.yml` via a manual `workflow_dispatch` with a `patch` / `minor` / `major` bump; `current` resumes an interrupted release after its version commit and tag exist. The workflow re-runs the full validation suite, computes the next version, updates -`Cargo.toml` and `Cargo.lock`, commits and tags `vX.Y.Z`, builds the TinyBus -module for every supported platform, pushes, and creates an immutable GitHub -release with installable native packages. +the root `[workspace.package]` version and `Cargo.lock`, commits and tags +`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported +platform, pushes, and creates an immutable GitHub release with installable +native packages. Consequently: -- Do not hand-edit the `version` field in `Cargo.toml`; the release workflow - owns it. +- Do not hand-edit the `version` field in the root `[workspace.package]`; the + release workflow owns it. Every member inherits it with + `version.workspace = true`, so the whole workspace releases as one version. - Follow semantic versioning. Any change to the public surface that is not purely additive is a breaking change and needs a major bump (pre-1.0: a minor bump). diff --git a/Cargo.lock b/Cargo.lock index ad52f44..c2df4c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,17 +303,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rust-template" -version = "0.1.5" -dependencies = [ - "serde_json", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - [[package]] name = "rustix" version = "1.1.4" @@ -478,6 +467,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "template" +version = "0.1.5" +dependencies = [ + "serde_json", + "template-bus", + "thiserror", + "tinybus", + "tinybus-module", + "tokio", +] + +[[package]] +name = "template-bus" +version = "0.1.5" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "thiserror" version = "2.0.20" diff --git a/Cargo.toml b/Cargo.toml index dddcbf9..8a6eada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,62 +1,68 @@ -[package] -name = "rust-template" +[workspace] +resolver = "3" +# Every crate in this repository lives under `crates/`, one directory per +# package, each directory named for the package it holds. There is no root +# package: the crate a host loads is `crates/template`, the same as any other +# member. Keeping the root virtual is what makes that uniform — a root package +# would make one crate structurally different from the rest for no reason other +# than history, and it is the arrangement this template moved away from. +members = ["crates/*"] +# `vendor/` holds the pinned TinyBus submodule, which is its own workspace with +# its own lockfile. `worktrees/` holds `git worktree` checkouts of this same +# repository; each contains a full copy of this manifest and every crate under +# it, so without this entry cargo walks into them and reports duplicate +# packages. +exclude = ["vendor", "worktrees"] + +# Shared package metadata. A member inherits a field with `field.workspace = +# true`, so the version the release workflow bumps is written in exactly one +# place and every crate moves together. +[workspace.package] version = "0.1.5" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -description = "A production-ready template for installable TinyBus modules." repository = "https://github.com/tinyhumansai/rust-template" -documentation = "https://docs.rs/rust-template" -readme = "README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] -publish = false -# Keep the published package to what a consumer actually needs. -exclude = [ - ".github/", - ".gitmodules", - "docs/", - "vendor/", - "worktrees/", - ".env.example", - "deny.toml", -] - -[lib] -# Keep the ordinary Rust library for tests and downstream reuse while also -# producing the native module artifact that TinyBus loads at runtime. -crate-type = ["rlib", "cdylib"] -[dependencies] -# TinyBus defines the message types, interface macro, and frozen module ABI used -# by the generated integration. Socket and CLI features are unnecessary here. -tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = ["macros", "modules"] } +[workspace.dependencies] +# The wire contract. `crates/template` depends on it and re-exports it, so a +# host that only makes calls takes this crate alone. +# No `version` requirement on purpose: the workspace version moves on every +# release, and a pinned requirement here would stop resolving the moment it did. +# Nothing in this workspace is published, so the path is the whole address. +template-bus = { path = "crates/template-bus" } +# TinyBus defines the message types, interface macro, and frozen module ABI +# used by the generated integration. Socket and CLI features are unnecessary +# here. +tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = [ + "macros", + "modules", +] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `src/error/mod.rs`. Every -# dependency entry should carry a comment like this one saying why it is here. +# Derive macros for the crate-wide error type in `crates/template/src/error/`. +# Every dependency entry should carry a comment like this one saying why it is +# here. thiserror = "2" - -[dev-dependencies] +# The bus payload types are serialized into TinyBus frames. +serde = { version = "1", features = ["derive"] } +# Positional argument arrays and the module configuration blob. +serde_json = "1" # Module integration tests exercise the real asynchronous in-memory TinyBus. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = "1" - -[features] -default = [] -# Lints apply to the whole crate and to every target. CI runs clippy with -# `-D warnings`, so anything set to "warn" here fails the build in CI. -[lints.rust] +# Lints apply to every member that opts in with `[lints] workspace = true`, and +# to every target of that member. CI runs clippy with `-D warnings`, so anything +# set to "warn" here fails the build in CI. +[workspace.lints.rust] unsafe_code = "forbid" missing_docs = "warn" missing_debug_implementations = "warn" unreachable_pub = "warn" rust_2018_idioms = { level = "warn", priority = -1 } -[lints.clippy] +[workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } # Library code must not panic on its own; tests and examples may. @@ -73,7 +79,7 @@ doc_markdown = "warn" # `#[must_use]` on pure public functions. must_use_candidate = "warn" -[lints.rustdoc] +[workspace.lints.rustdoc] broken_intra_doc_links = "warn" private_intra_doc_links = "warn" diff --git a/MODULE.md b/MODULE.md index f0da19b..651906e 100644 --- a/MODULE.md +++ b/MODULE.md @@ -1,12 +1,15 @@ -# Rust Template TinyBus Module +# Template TinyBus Module -This package contains the native `rust-template` module for TinyBus module ABI +This package contains the native `template` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. -The module claims `ai.tinyhumans.rust_template.Greeting`, serves the object at -`/ai/tinyhumans/rust_template/Greeting`, and provides the `Greet` method. The -method accepts one string and returns `Hello, !`; empty names are rejected. +The module claims `ai.tinyhumans.template.Greeting`, serves the object at +`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The +method accepts a `GreetRequest` and returns a `GreetResponse` carrying +`Hello, !`; empty names are rejected. Both payload types, the interface +name, the object path, and the member names are published as the `template-bus` +crate, so a host names them from a library rather than by string literal. The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep those files together when copying them into a TinyBus module directory. The @@ -19,8 +22,8 @@ archive. Install directly from a tagged release with: ```sh tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.4 \ - rust-template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ + https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ + template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ ``` diff --git a/README.md b/README.md index 2d5f3ff..67a4e39 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,44 @@ # Rust Template A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the module layout, TinyBus ABI adapter, error handling, testing, +ships the workspace layout, TinyBus ABI adapter, error handling, testing, documentation, CI, and multi-platform release workflow that every new integration in this organization starts from. +It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — +member names, payload types, and the contract version, with no transport and no +behavior — and `crates/template` is the implementation, built as both an `rlib` +and the `cdylib` TinyBus loads. A host that only makes calls depends on the +contract crate alone and compiles neither the module nor `tinybus` itself. + ## Use This Template Choose **Use this template** on GitHub, create a repository, then work through the checklist at the top of [`AGENTS.md`](AGENTS.md): -- update the package name, description, repository, keywords, and categories in - `Cargo.toml`; -- update this README and the crate documentation in `src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area; -- rename the TinyBus interface, object path, and exported methods in - `src/tinybus_module/`; +- rename the `crates/template` and `crates/template-bus` directories and the + `name` fields in their manifests, and set the shared `description`, + `repository`, `keywords`, and `categories`; +- update this README and the crate documentation in `crates/template/src/lib.rs`; +- replace the placeholder `greeting` module with the first real feature area, in + both crates: the payload types in the contract, the behavior in the module; +- rename the TinyBus interface, object path, and member constants in + `crates/template-bus/src/names/`, and the matching `provides` / `methods` + declarations in `crates/template/src/tinybus_module/`; - update the security contact and repository links in the community files; - replace `ROADMAP.md` with the real plan, or delete it; - change the license if GPL-3.0-only is not appropriate. -Search for `rust-template` and `rust_template` to find every remaining +Search for `template` and `template_bus` to find every remaining template-specific value. ## What You Get | Area | What is configured | | --- | --- | -| Layout | Directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared in `[lints]` so local and CI runs agree | -| CI | Format, clippy, build, test (default and all features), at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | +| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | +| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | +| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | | Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | | Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | | Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | @@ -38,23 +47,30 @@ template-specific value. ## Layout ```text -src/ -├── lib.rs # crate docs + the entire public re-export surface -├── error/ -│ ├── mod.rs # crate-wide `Error` and `Result` -│ └── test.rs -├── greeting/ # one directory per feature area - ├── mod.rs # module docs, wiring, smallest useful public API - └── test.rs # module-local unit tests -└── tinybus_module/ - ├── mod.rs # bus interface, setup, and ABI v1 exports - └── test.rs # real in-memory TinyBus integration tests -tests/ -└── public_api.rs # integration tests against the public API only -examples/ -├── basic.rs # ordinary library API usage -├── verify_module.rs # local dynamic-module verification -└── verify_github_release.rs # tagged-release download and bus call +Cargo.toml # virtual workspace: members, shared metadata, lints +crates/ +├── template-bus/ # the wire contract — what crosses the bus +│ ├── README.md # why the contract is its own crate +│ └── src/ +│ ├── lib.rs # crate docs + the entire public re-export surface +│ ├── names/ # interface, object path, one constant per member +│ ├── greeting/ # payload types, one directory per family +│ │ ├── mod.rs +│ │ ├── types.rs +│ │ └── test.rs +│ └── version/ # contract version and the host bind rule +└── template/ # the module — behavior, adapter, and the cdylib + ├── src/ + │ ├── lib.rs # crate docs + public surface, re-exporting the contract + │ ├── error/ # crate-wide `Error` and `Result` + │ ├── greeting/ # one directory per feature area + │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports + ├── tests/ + │ └── public_api.rs # integration tests against the public API only + └── examples/ + ├── basic.rs # ordinary library API usage + ├── verify_module.rs # local dynamic-module verification + └── verify_github_release.rs # tagged-release download and bus call vendor/ └── tinybus/ # pinned TinyBus git submodule docs/ @@ -64,10 +80,19 @@ docs/ └── adr/ # immutable architecture decision records ``` -Feature areas use directory modules: implementation and exports live in -`mod.rs`, substantial types move to `types.rs`, and unit tests live in -`test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository guidance, and -`CLAUDE.md` is a symlink to it so every coding agent reads one source of truth. +The split is the point. A payload type describes what a frame carries; the +behavior that answers it is a different obligation. `template` depends on +`template-bus` and re-exports all of it, so `template::GreetRequest` and +`template_bus::GreetRequest` are the *same* type rather than structural twins, +and a host is never forced to choose between linking the whole module and +redefining the vocabulary. See +[`crates/template-bus/README.md`](crates/template-bus/README.md). + +Within each crate, feature areas use directory modules: implementation and +exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests +live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository +guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one +source of truth. ## Development @@ -82,8 +107,8 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run --example basic -cargo build --release --lib # produces the installable cdylib +cargo run -p template --example basic +cargo build -p template --release --lib # produces the installable cdylib ``` Those four checks are exactly what CI runs. Optional extras: @@ -92,16 +117,17 @@ Those four checks are exactly what CI runs. Optional extras: cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" cargo deny check all # supply-chain check; see deny.toml cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 target/coverage.json +.github/scripts/check-file-coverage.sh 90 coverage.json ``` ## Releasing Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or `major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the crate, versions and -tags it, builds this crate as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `rust-template--.` and contain the +commit and tag already exist. The workflow revalidates the workspace, versions +and tags it — one `[workspace.package]` version that every member inherits — +builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. +Assets follow `template--.` and contain the native module, its SHA-256 `modules.toml`, license, and [`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which TinyBus uses to verify an archive before extraction. The workflow loads the @@ -112,8 +138,8 @@ matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in -`Cargo.toml`. +architecture images are not release gates. Do not hand-edit the version in the +root `Cargo.toml`. ## Documentation diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml new file mode 100644 index 0000000..a30dd85 --- /dev/null +++ b/crates/template-bus/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "template-bus" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." +documentation = "https://docs.rs/template-bus" +readme = "README.md" +keywords = ["tinybus", "module", "contract", "template"] +categories = ["development-tools"] +publish = false + +# Deliberately dependency-light: this is the crate a host links to talk to the +# loadable module, so it must cost that host almost nothing. Nothing here may +# pull in `tinybus`, an async runtime, an HTTP client, or a native library — +# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md new file mode 100644 index 0000000..7f8e99c --- /dev/null +++ b/crates/template-bus/README.md @@ -0,0 +1,100 @@ +# template-bus + +Every type that crosses the template module's `TinyBus` boundary, and the names +of the members that carry them. + +The template ships as a loadable module so a host does not compile the +implementation: `crates/template` is built as a `cdylib` and exports one object. +A host can load that binary but cannot `use` anything out of it, so the payload +vocabulary has to be published as an ordinary library. This is it. + +| module | what it holds | +| ---------- | ------------------------------------------------------------ | +| `names` | interface name, object path, one constant per member | +| `greeting` | the value vocabulary: the `Greet` request and response | +| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | + +Two dependencies, both pure Rust: `serde` and `serde_json`. + +## This crate sits underneath `template` + +`template` **depends on this crate and re-exports all of it**. That direction +matters, and it is the opposite of the obvious one. + +A *host* needs the payload types and needs nothing else: it loads the module and +makes calls, so it names `GreetRequest` and `GreetResponse` but implements no +behavior and links no transport. Making it depend on the whole module crate — and +through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type +would be the wrong shape. + +The alternative, a parallel set of payload types for hosts, is worse: a +`GreetRequest` defined twice is two distinct types, with a conversion at every +call site that nothing checks. One definition, here, at the bottom. + +Because the re-export is by module as well as by item, `template::GreetRequest`, +`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all +resolve to the same items, not twins. + +So: a module author depends on `template` and gets behavior and vocabulary. A +host depends on `template-bus` and gets vocabulary alone. + +## What is deliberately absent + +**No behavior.** `greet` lives in `crates/template`. A payload type describes +what a frame carries, not what the module does with it. The split is readable +off the path: a name here is data, a name there is an obligation. + +**No transport.** This crate does not depend on `tinybus` and holds no +connection, client, or codec. A host already owns its connection — its reconnect +policy, its timeouts, its tracing — and the useful part is the vocabulary. + +That is also structural, not just preference: `tinybus` is vendored as a +submodule whose manifest inherits fields from its own nested +`[workspace.package]`. Keeping the contract crate transport-free is what keeps +it down to two dependencies and what lets anything in the workspace — or outside +it — depend on it freely. CI asserts the dependency tree stays that way. + +## Making a call + +Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes +them into a tuple — and the member name comes from `names`: + +```rust,ignore +use template_bus::{names, GreetRequest, GreetResponse}; + +let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; +let reply: GreetResponse = proxy + .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) + .await?; +assert_eq!(reply.greeting, "Hello, Ferris!"); +``` + +Nothing above is a string literal at a call site. Renaming the interface, the +path, or a member is therefore a compile error in every consumer rather than an +`UnknownMethod` discovered at runtime. + +## Staying in step with the module + +`names::METHODS` lists every member in dispatch order. `crates/template` asserts +its served members against that list, so a method added to the interface without +an entry here fails that crate's tests rather than surfacing in a host. + +## Versioning + +`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major +component when a payload's wire form changes incompatibly or a member is removed +or renamed, and its minor component when a member or an optional field is added. +It is deliberately independent of the package version the release workflow owns, +which tracks the shipped artifact. + +The payload tests pin the serde representation, because that representation is +the wire form: a host and a module that disagree about a field name fail at +runtime with a decode error, so the shape is asserted rather than assumed. + +## Generating a project from the template + +Rename the interface, the object path, and the member constants in `names` +together, replace `greeting` with the first real payload family, and reset +`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate +dependency-light: the moment it links a transport or a runtime, the reason it +exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs new file mode 100644 index 0000000..f810aab --- /dev/null +++ b/crates/template-bus/src/greeting/mod.rs @@ -0,0 +1,17 @@ +//! The payloads the `Greet` member exchanges. +//! +//! A module root like this one documents the module, wires its pieces together, +//! and exposes the smallest useful API. The type definitions live in the +//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom +//! of this file. +//! +//! Replace this module with the first real payload family the module carries. +//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: +//! they are decoded from a frame, so they can borrow nothing from the caller. + +mod types; + +pub use types::{GreetRequest, GreetResponse}; + +#[cfg(test)] +mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs new file mode 100644 index 0000000..1a30000 --- /dev/null +++ b/crates/template-bus/src/greeting/test.rs @@ -0,0 +1,65 @@ +//! Unit tests for the `Greet` payloads. +//! +//! These pin the serde representation. It is the wire form: a host and a module +//! that disagree about a field name fail at runtime with a decode error, so the +//! shape is asserted here rather than assumed. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{GreetRequest, GreetResponse}; + +#[test] +fn a_request_serializes_to_its_wire_form() { + let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); + assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); +} + +#[test] +fn a_response_serializes_to_its_wire_form() { + let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); + assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); +} + +#[test] +fn a_request_round_trips_through_json() { + let request = GreetRequest::new(" Ferris "); + let encoded = serde_json::to_string(&request).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + request + ); +} + +#[test] +fn a_response_round_trips_through_json() { + let response = GreetResponse::new("Hello, Ferris!"); + let encoded = serde_json::to_string(&response).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + response + ); +} + +#[test] +fn a_request_missing_its_name_is_rejected() { + let decoded = serde_json::from_value::(serde_json::json!({})); + assert!(decoded.is_err()); +} + +#[test] +fn a_response_missing_its_greeting_is_rejected() { + let decoded = serde_json::from_value::(serde_json::json!({})); + assert!(decoded.is_err()); +} + +#[test] +fn constructors_accept_both_borrowed_and_owned_names() { + assert_eq!( + GreetRequest::new(String::from("Ferris")), + GreetRequest::new("Ferris") + ); + assert_eq!( + GreetResponse::new(String::from("Hi")), + GreetResponse::new("Hi") + ); +} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs new file mode 100644 index 0000000..d70b376 --- /dev/null +++ b/crates/template-bus/src/greeting/types.rs @@ -0,0 +1,54 @@ +//! Request and response types for the `Greet` member. + +use serde::{Deserialize, Serialize}; + +/// The argument to [`crate::names::methods::GREET`]. +/// +/// The module trims surrounding whitespace from [`GreetRequest::name`] and +/// rejects a name that is empty once trimmed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct GreetRequest { + /// The name to greet. + pub name: String, +} + +impl GreetRequest { + /// Builds a request greeting `name`. + /// + /// # Examples + /// + /// ``` + /// # use template_bus::GreetRequest; + /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); + /// ``` + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +/// The reply from [`crate::names::methods::GREET`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct GreetResponse { + /// The rendered greeting. + pub greeting: String, +} + +impl GreetResponse { + /// Builds a reply carrying `greeting`. + /// + /// # Examples + /// + /// ``` + /// # use template_bus::GreetResponse; + /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); + /// ``` + #[must_use] + pub fn new(greeting: impl Into) -> Self { + Self { + greeting: greeting.into(), + } + } +} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs new file mode 100644 index 0000000..a1857d1 --- /dev/null +++ b/crates/template-bus/src/lib.rs @@ -0,0 +1,74 @@ +//! Every type that crosses the template module's `TinyBus` boundary, and the +//! names of the members that carry them. +//! +//! This crate ships as a loadable `TinyBus` module: `crates/template` is built +//! as a `cdylib` and exports one object. A host that loads that binary can call +//! into it but cannot `use` anything out of it, so the payload vocabulary has +//! to be published as an ordinary library. This is that library. +//! +//! # What is here +//! +//! - [`names`] — the interface name, the object path, and one constant per +//! member, plus [`names::METHODS`] listing them in dispatch order. +//! - [`greeting`] — the value vocabulary: the request and response payloads the +//! `Greet` member exchanges. +//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. +//! +//! # What is deliberately not here +//! +//! **No behavior.** The `greet` implementation lives in `crates/template`, +//! which depends on this crate and re-exports it. A payload type describes what +//! a frame carries, not what the module does with it. +//! +//! **No transport.** This crate does not depend on `tinybus` and holds no +//! connection, client, or codec. A host already owns its connection — its +//! reconnect policy, its timeouts, its tracing — and the useful part is the +//! vocabulary, not another wrapper around it. +//! +//! That is also a structural necessity, not only a preference: `tinybus` is +//! vendored as a submodule whose manifest inherits fields from its own nested +//! `[workspace.package]`. A crate that every workspace member can depend on has +//! to stay transport-free, and staying transport-free is what keeps this crate +//! down to two pure-Rust dependencies. +//! +//! # This crate sits underneath the implementation, not beside it +//! +//! `template` **depends on this crate and re-exports all of it**, so +//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the +//! *same type*, not structural twins. Defining a parallel set of payload types +//! for hosts would mean a conversion at every call site that nothing checks. +//! One definition, here, at the bottom. +//! +//! So: a module author depends on `template` and gets behavior and vocabulary. +//! A host depends on `template-bus` and gets vocabulary alone. +//! +//! # Staying in step with the module +//! +//! [`names::METHODS`] lists every member. `crates/template` asserts its served +//! members against that list, in order, so a method added to the interface +//! without an entry here fails that crate's tests rather than surfacing as an +//! unknown method in a host at runtime. +//! +//! # Example +//! +//! ``` +//! use template_bus::{names, GreetRequest, GreetResponse}; +//! +//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; +//! assert_eq!(names::methods::GREET, "Greet"); +//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); +//! +//! let reply: GreetResponse = serde_json::from_value( +//! serde_json::json!({ "greeting": "Hello, Ferris!" }), +//! )?; +//! assert_eq!(reply.greeting, "Hello, Ferris!"); +//! # Ok::<(), serde_json::Error>(()) +//! ``` + +pub mod greeting; +pub mod names; +pub mod version; + +pub use greeting::{GreetRequest, GreetResponse}; +pub use names::{INTERFACE, METHODS, OBJECT_PATH}; +pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs new file mode 100644 index 0000000..4da1547 --- /dev/null +++ b/crates/template-bus/src/names/mod.rs @@ -0,0 +1,33 @@ +//! The bus identity of the template module: interface name, object path, and +//! one constant per member. +//! +//! Nothing here is a string literal at a call site. A host names a member +//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a +//! compile error in every consumer rather than a runtime "unknown method". +//! +//! When generating a project from this template, rename all three together — +//! the interface, the path, and the member constants — and keep +//! [`METHODS`] in the same order as the interface's dispatch table. + +/// The well-known interface name the module claims on the bus. +pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; + +/// The object path the module serves its interface at. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; + +/// One constant per member of [`INTERFACE`]. +pub mod methods { + /// Builds a greeting for a name. + /// + /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. + pub const GREET: &str = "Greet"; +} + +/// Every member of [`INTERFACE`], in the order the interface dispatches them. +/// +/// `crates/template` asserts its declared manifest methods against this list, +/// so the two cannot drift. +pub const METHODS: &[&str] = &[methods::GREET]; + +#[cfg(test)] +mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs new file mode 100644 index 0000000..bf7bea2 --- /dev/null +++ b/crates/template-bus/src/names/test.rs @@ -0,0 +1,28 @@ +//! Unit tests for the bus name table. + +use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; + +#[test] +fn the_object_path_is_the_interface_in_path_form() { + let expected = format!("/{}", INTERFACE.replace('.', "/")); + assert_eq!(OBJECT_PATH, expected); +} + +#[test] +fn every_member_is_listed_exactly_once() { + let mut sorted = METHODS.to_vec(); + sorted.sort_unstable(); + let mut deduplicated = sorted.clone(); + deduplicated.dedup(); + assert_eq!(sorted, deduplicated); +} + +#[test] +fn the_method_table_holds_the_declared_members() { + assert_eq!(METHODS, [methods::GREET]); +} + +#[test] +fn no_member_name_is_empty() { + assert!(METHODS.iter().all(|method| !method.is_empty())); +} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs new file mode 100644 index 0000000..ada372d --- /dev/null +++ b/crates/template-bus/src/version/mod.rs @@ -0,0 +1,46 @@ +//! The contract version, and the rule a host uses to decide whether it can bind +//! to a module that reports one. +//! +//! The version describes *this vocabulary*, not the crate: bump the major +//! component when a payload's wire form changes incompatibly or a member is +//! removed or renamed, and the minor component when a member or an optional +//! field is added. It is deliberately independent of the package version the +//! release workflow bumps, which tracks the shipped artifact. + +/// The wire contract version this crate defines. +pub const CONTRACT_VERSION: (u32, u32) = (1, 0); + +/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module +/// reporting `module`. +/// +/// Compatibility is the ordinary semantic-version rule for a pre-release-free +/// contract: the majors must match, and the module must be at least as new as +/// the host, because a host cannot call a member a module does not serve. +/// +/// # Examples +/// +/// ``` +/// # use template_bus::{is_compatible, CONTRACT_VERSION}; +/// assert!(is_compatible(CONTRACT_VERSION)); +/// assert!(is_compatible((1, 4))); +/// assert!(!is_compatible((2, 0))); +/// ``` +#[must_use] +pub fn is_compatible(module: (u32, u32)) -> bool { + binds(CONTRACT_VERSION, module) +} + +/// The bind rule with the host version supplied explicitly. +/// +/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is +/// split out so the unit tests can exercise both directions of the comparison +/// without pinning them to whatever the shipped version happens to be. +fn binds(host: (u32, u32), module: (u32, u32)) -> bool { + let (host_major, host_minor) = host; + let (module_major, module_minor) = module; + + module_major == host_major && module_minor >= host_minor +} + +#[cfg(test)] +mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs new file mode 100644 index 0000000..3fd3edf --- /dev/null +++ b/crates/template-bus/src/version/test.rs @@ -0,0 +1,34 @@ +//! Unit tests for the contract version and its bind rule. + +use super::{CONTRACT_VERSION, binds, is_compatible}; + +#[test] +fn the_shipped_contract_version_is_pinned() { + assert_eq!(CONTRACT_VERSION, (1, 0)); +} + +#[test] +fn the_contract_binds_to_itself() { + assert!(is_compatible(CONTRACT_VERSION)); +} + +#[test] +fn a_newer_minor_on_the_module_side_binds() { + assert!(is_compatible((1, 1))); + assert!(is_compatible((1, 97))); +} + +#[test] +fn an_older_minor_on_the_module_side_is_rejected() { + // A host built against 1.4 cannot call a 1.2 module: the members it names + // may not be served. + assert!(!binds((1, 4), (1, 2))); + assert!(binds((1, 4), (1, 4))); +} + +#[test] +fn a_different_major_is_rejected() { + assert!(!is_compatible((0, 0))); + assert!(!is_compatible((2, 0))); + assert!(!is_compatible((2, 97))); +} diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml new file mode 100644 index 0000000..e1bcdf4 --- /dev/null +++ b/crates/template/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "template" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "A production-ready template for installable TinyBus modules." +documentation = "https://docs.rs/template" +readme = "../../README.md" +keywords = ["tinybus", "module", "plugin", "template"] +categories = ["development-tools"] +publish = false + +[lib] +# Keep the ordinary Rust library for tests and downstream reuse while also +# producing the native module artifact that TinyBus loads at runtime. +crate-type = ["rlib", "cdylib"] + +[dependencies] +# The wire contract: member names, payload types, and the contract version. +# Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency +# rather than two, and so `template::GreetRequest` and +# `template_bus::GreetRequest` are the same type. +template-bus = { workspace = true } +tinybus = { workspace = true } +tinybus-module = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +# The GitHub release verifier passes an explicit empty module configuration. +serde_json = { workspace = true } + +[features] +default = [] + +[lints] +workspace = true diff --git a/examples/basic.rs b/crates/template/examples/basic.rs similarity index 93% rename from examples/basic.rs rename to crates/template/examples/basic.rs index 6fa02b8..99233ec 100644 --- a/examples/basic.rs +++ b/crates/template/examples/basic.rs @@ -7,7 +7,7 @@ //! cargo run --example basic //! ``` -use rust_template::{Result, greet}; +use template::{Result, greet}; fn main() -> Result<()> { println!("{}", greet("Rust")?); diff --git a/examples/verify_github_release.rs b/crates/template/examples/verify_github_release.rs similarity index 78% rename from examples/verify_github_release.rs rename to crates/template/examples/verify_github_release.rs index 3752fc8..9b173fe 100644 --- a/examples/verify_github_release.rs +++ b/crates/template/examples/verify_github_release.rs @@ -4,22 +4,20 @@ //! //! ```text //! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.4 \ -//! rust-template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ +//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ +//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ //! //! ``` use std::io; use std::time::Duration; +use template::{GreetRequest, GreetResponse, names}; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; - #[tokio::main] async fn main() -> Result<(), Box> { let (release_url, archive, sha256) = arguments()?; @@ -46,8 +44,8 @@ async fn main() -> Result<(), Box> { let client = Connection::connect(bus.connect().await?).await?; tokio::time::timeout(Duration::from_secs(5), async { loop { - let names = client.list_names().await?; - if names.iter().any(|name| name.as_str() == INTERFACE) { + let claimed = client.list_names().await?; + if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { return tinybus::Result::Ok(()); } tokio::task::yield_now().await; @@ -55,11 +53,14 @@ async fn main() -> Result<(), Box> { }) .await??; - let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("TinyBus",)).await?; - if greeting != "Hello, TinyBus!" { + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let reply: GreetResponse = proxy + .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) + .await?; + if reply.greeting != "Hello, TinyBus!" { return Err(io::Error::other(format!( - "module returned an unexpected greeting: {greeting}" + "module returned an unexpected greeting: {}", + reply.greeting )) .into()); } diff --git a/examples/verify_module.rs b/crates/template/examples/verify_module.rs similarity index 75% rename from examples/verify_module.rs rename to crates/template/examples/verify_module.rs index 3b8ae2e..6e3856e 100644 --- a/examples/verify_module.rs +++ b/crates/template/examples/verify_module.rs @@ -4,14 +4,12 @@ use std::io; use std::path::PathBuf; use std::time::Duration; +use template::{GreetRequest, GreetResponse, names}; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; - #[tokio::main] async fn main() -> Result<(), Box> { let module = module_argument()?; @@ -33,8 +31,8 @@ async fn main() -> Result<(), Box> { let client = Connection::connect(bus.connect().await?).await?; tokio::time::timeout(Duration::from_secs(5), async { loop { - let names = client.list_names().await?; - if names.iter().any(|name| name.as_str() == INTERFACE) { + let claimed = client.list_names().await?; + if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { return tinybus::Result::Ok(()); } tokio::task::yield_now().await; @@ -42,11 +40,14 @@ async fn main() -> Result<(), Box> { }) .await??; - let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("TinyBus",)).await?; - if greeting != "Hello, TinyBus!" { + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let reply: GreetResponse = proxy + .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) + .await?; + if reply.greeting != "Hello, TinyBus!" { return Err(io::Error::other(format!( - "module returned an unexpected greeting: {greeting}" + "module returned an unexpected greeting: {}", + reply.greeting )) .into()); } diff --git a/src/error/mod.rs b/crates/template/src/error/mod.rs similarity index 100% rename from src/error/mod.rs rename to crates/template/src/error/mod.rs diff --git a/src/error/test.rs b/crates/template/src/error/test.rs similarity index 100% rename from src/error/test.rs rename to crates/template/src/error/test.rs diff --git a/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs similarity index 92% rename from src/greeting/mod.rs rename to crates/template/src/greeting/mod.rs index 5b4ad65..862fa21 100644 --- a/src/greeting/mod.rs +++ b/crates/template/src/greeting/mod.rs @@ -16,9 +16,9 @@ use crate::{Error, Result}; /// # Examples /// /// ``` -/// # use rust_template::greet; +/// # use template::greet; /// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), rust_template::Error>(()) +/// # Ok::<(), template::Error>(()) /// ``` /// /// # Errors diff --git a/src/greeting/test.rs b/crates/template/src/greeting/test.rs similarity index 100% rename from src/greeting/test.rs rename to crates/template/src/greeting/test.rs diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs new file mode 100644 index 0000000..566fa7e --- /dev/null +++ b/crates/template/src/lib.rs @@ -0,0 +1,62 @@ +//! A production-ready starting point for an installable `TinyBus` module. +//! +//! This crate is a template. It ships the layout, lint configuration, error +//! handling, testing, and documentation conventions described in `AGENTS.md`. +//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example +//! [`greet`] behavior over the bus. +//! +//! # Layout +//! +//! This is the implementation half of a two-crate workspace: +//! +//! - [`template_bus`] — the wire contract. Member names, payload types, and the +//! contract version, with no transport and no behavior. A host that only +//! makes calls depends on that crate alone. +//! - `template` — this crate. The behavior, the crate-wide error type, and the +//! `TinyBus` adapter that serves them, built as both an `rlib` and the +//! `cdylib` the loader consumes. +//! +//! Within this crate: +//! +//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias +//! returned by every fallible public function. +//! - Each feature area lives in its own module directory with a `mod.rs` +//! module root, an optional `types.rs`, and a `test.rs` holding its unit +//! tests. +//! - Every public item is re-exported from here — including all of +//! [`template_bus`] — so downstream users have a single predictable surface +//! and `template::GreetRequest` is the *same type* as +//! `template_bus::GreetRequest`, not a structural twin. +//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the +//! module descriptor, embedded manifest, and initialization entrypoint. +//! +//! # Example +//! +//! ``` +//! use template::{greet, Error, GreetRequest}; +//! +//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); +//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); +//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); +//! # Ok::<(), template::Error>(()) +//! ``` +//! +//! Replace the `greeting` module with the first real feature area, keep the +//! conventions, and update this documentation to describe the new crate. + +mod error; +mod greeting; +mod tinybus_module; + +pub use error::{Error, Result}; +pub use greeting::greet; + +// The wire contract, re-exported by module rather than by item so every path +// through this crate resolves to the same definitions the contract crate +// publishes. A host may depend on `template-bus` directly and get exactly these +// types; nothing here redefines them. +pub use template_bus; +pub use template_bus::{ + CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, + names, version, +}; diff --git a/crates/template/src/tinybus_module/README.md b/crates/template/src/tinybus_module/README.md new file mode 100644 index 0000000..2c05772 --- /dev/null +++ b/crates/template/src/tinybus_module/README.md @@ -0,0 +1,20 @@ +# TinyBus Adapter + +This module is the boundary between ordinary feature code and TinyBus module +ABI v1. `GreetingService` converts the crate's public `greet` function into the typed +`Greet` bus method, while `setup` registers its object and claims the well-known +interface name. Neither the name, the object path, nor the payload types are +spelled here: they come from `template-bus`, so a rename is a compile error in +every consumer instead of an `UnknownMethod` at runtime. + +`tinybus_module::module_export!` emits the descriptor, embedded manifest, and +initialization symbols consumed by the dynamic loader. The manifest method list +must stay aligned with the interface macro's dispatch table and with +`template_bus::names::METHODS`; the unit tests check both relationships. +Integration tests use TinyBus's in-memory transport, and +`crates/template/examples/verify_module.rs` loads a compiled `cdylib` through +the real dynamic loader before a release archive is accepted. + +Generated projects should replace the example interface, object path, and method +declarations together — here and in `crates/template-bus/src/names/`. They must not retain Rust-owned data across the +ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs new file mode 100644 index 0000000..1c9c2f0 --- /dev/null +++ b/crates/template/src/tinybus_module/mod.rs @@ -0,0 +1,43 @@ +//! `TinyBus` module entrypoint and bus-facing interface. +//! +//! This adapter keeps the feature implementation independent from `TinyBus` +//! while exposing it as an installable, dynamically loaded integration. The +//! names and payload types it serves come from [`template_bus`], so a host +//! spells them from the contract crate instead of repeating string literals. + +use template_bus::{GreetRequest, GreetResponse, names}; +use tinybus::{Connection, Result as TinyBusResult}; + +struct GreetingService; + +#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] +impl GreetingService { + async fn greet(&self, request: GreetRequest) -> TinyBusResult { + std::future::ready(crate::greet(&request.name)) + .await + .map(GreetResponse::new) + .map_err(|error| tinybus::Error::failed(error.to_string())) + } +} + +async fn setup(connection: Connection) -> TinyBusResult<()> { + connection + .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) + .await?; + connection.request_name(names::INTERFACE).await?; + Ok(()) +} + +tinybus_module::module_export! { + setup = setup, + worker_threads = 1, + provides = ["ai.tinyhumans.template.Greeting"], + methods = ["Greet"], + signals = [], + requires = [], + optional = [], + lazy = false, +} + +#[cfg(test)] +mod test; diff --git a/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs similarity index 63% rename from src/tinybus_module/test.rs rename to crates/template/src/tinybus_module/test.rs index c869197..d5fe71a 100644 --- a/src/tinybus_module/test.rs +++ b/crates/template/src/tinybus_module/test.rs @@ -1,6 +1,7 @@ //! Tests for the `TinyBus` module adapter and its declared surface. -use super::{GreetingService, INTERFACE, OBJECT_PATH, setup}; +use super::{GreetingService, setup}; +use template_bus::{GreetRequest, GreetResponse, names}; use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Interface}; @@ -13,7 +14,12 @@ fn declared_methods_match_the_dispatch_table() { .map(|member| member.to_string()) .collect::>(); - assert_eq!(methods, ["Greet"]); + assert_eq!(methods, names::METHODS.to_vec()); +} + +#[test] +fn the_served_interface_name_matches_the_contract() { + assert_eq!(GreetingService.name().to_string(), names::INTERFACE); } #[tokio::test] @@ -25,10 +31,12 @@ async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { setup(service.clone()).await?; let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("Ferris",)).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let reply: GreetResponse = proxy + .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) + .await?; - assert_eq!(greeting, "Hello, Ferris!"); + assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); Ok(()) } @@ -41,8 +49,10 @@ async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { setup(service.clone()).await?; let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let result = proxy.call::("Greet", (" ",)).await; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let result = proxy + .call::(names::methods::GREET, (GreetRequest::new(" "),)) + .await; let Err(error) = result else { return Err(tinybus::Error::failed( diff --git a/tests/public_api.rs b/crates/template/tests/public_api.rs similarity index 94% rename from tests/public_api.rs rename to crates/template/tests/public_api.rs index 4ee1e4b..256b71c 100644 --- a/tests/public_api.rs +++ b/crates/template/tests/public_api.rs @@ -7,7 +7,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use rust_template::{Error, greet}; +use template::{Error, greet}; #[test] fn greeting_is_available_to_consumers() { diff --git a/deny.toml b/deny.toml index f4a5d09..1134b6a 100644 --- a/deny.toml +++ b/deny.toml @@ -32,6 +32,16 @@ confidence-threshold = 0.9 # Duplicate versions bloat build times; review them rather than ignoring them. multiple-versions = "warn" wildcards = "deny" +# A `version` requirement on a workspace-internal path dependency is a trap: it +# is a caret range, so the first minor bump past `0.1.x` — or any 1.0 release — +# stops resolving, and the release workflow discovers it after the tag is +# pushed. The path is the whole address for a crate that is never published, so +# those entries carry no version and are wildcards by construction. +# +# This exemption is narrow: it applies only to path dependencies on crates whose +# manifest sets `publish = false`. A wildcard on anything from a registry is +# still denied, which is what this check exists for. +allow-wildcard-paths = true # Crates that must never enter the dependency graph. deny = [] diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md index 0590963..fe42c4c 100644 --- a/docs/plans/example-retry-policy.md +++ b/docs/plans/example-retry-policy.md @@ -54,7 +54,7 @@ without adding a runtime, timers, or new dependencies. **Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` 1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `rust_template::{Error, RetryPolicy}`. +2. Add an integration test using only `template::{Error, RetryPolicy}`. 3. Add a runnable README example and rustdoc `# Errors` documentation. 4. Run `cargo test --doc` and `cargo test --test public_api`. diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md index 66c1dcf..f9eaa18 100644 --- a/docs/plans/tinybus-module-release.md +++ b/docs/plans/tinybus-module-release.md @@ -5,7 +5,7 @@ Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-mo 1. Add the pinned TinyBus host types and module SDK as path dependencies. 2. Export the template greeting behavior through TinyBus module ABI v1. 3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `rust-template` module archives for +4. Replace TinyBus host bundles with tagged `template` module archives for every supported platform runner and distribution container. 5. Run the repository validation and coverage contracts, push `main`, and trigger a patch release. diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md index 39f399d..fe0c1a8 100644 --- a/docs/specs/example-retry-policy.md +++ b/docs/specs/example-retry-policy.md @@ -29,7 +29,7 @@ and validation. The crate currently has no retry behavior. The public surface is deliberately small: ```rust -use rust_template::{RetryPolicy, Result}; +use template::{RetryPolicy, Result}; fn policy() -> Result { let policy = RetryPolicy::new(3)?; diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md index d4f3a76..adae9b4 100644 --- a/docs/specs/tinybus-module-release.md +++ b/docs/specs/tinybus-module-release.md @@ -10,10 +10,10 @@ distributable without also shipping the TinyBus host runtime. - The library builds as both an `rlib` and a native `cdylib`. - The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the initialization entrypoint. -- The example module provides `ai.tinyhumans.rust_template.Greeting.Greet` at - `/ai/tinyhumans/rust_template/Greeting`. +- The example module provides `ai.tinyhumans.template.Greeting.Greet` at + `/ai/tinyhumans/template/Greeting`. - Each release archive is named - `rust-template--.` and contains only this + `template--.` and contains only this module, its SHA-256 `modules.toml`, license, and installation documentation. - Each GitHub release publishes a separate `checksum.toml` mapping every archive filename to its SHA-256 digest for TinyBus's release loader. diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 170bf55..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here, so downstream users have a -//! single predictable surface. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use rust_template::{greet, Error}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! # Ok::<(), rust_template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; diff --git a/src/tinybus_module/README.md b/src/tinybus_module/README.md deleted file mode 100644 index 1cece84..0000000 --- a/src/tinybus_module/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# TinyBus Adapter - -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the -typed `Greet` bus method, while `setup` registers its object and claims the -well-known interface name. - -`tinybus_module::module_export!` emits the descriptor, embedded manifest, and -initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table; the unit test -checks that relationship. Integration tests use TinyBus's in-memory transport, -and `examples/verify_module.rs` loads a compiled `cdylib` through the real -dynamic loader before a release archive is accepted. - -Generated projects should replace the example interface, object path, and -method declarations together. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/src/tinybus_module/mod.rs b/src/tinybus_module/mod.rs deleted file mode 100644 index 19feda1..0000000 --- a/src/tinybus_module/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` while -//! exposing it as an installable, dynamically loaded integration. - -use tinybus::{Connection, Result as TinyBusResult}; - -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.rust_template.Greeting")] -impl GreetingService { - async fn greet(&self, name: String) -> TinyBusResult { - std::future::ready(crate::greet(&name)) - .await - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.rust_template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test;