diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8fb51dd..9fbfc6f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,7 @@ Commands actually run, with their outcome: - [ ] `cargo clippy --all-targets --all-features -- -D warnings` - [ ] `cargo build --all-targets --all-features` - [ ] `cargo test --all-features` +- [ ] `.github/scripts/check-file-coverage.sh 90 coverage.json` ## Tests diff --git a/.github/scripts/check-file-coverage.sh b/.github/scripts/check-file-coverage.sh new file mode 100755 index 0000000..95da178 --- /dev/null +++ b/.github/scripts/check-file-coverage.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +minimum="${1:-90}" +report="${2:-coverage.json}" +workspace_root="$(pwd -P)/" +source_root="${workspace_root}src/" + +cargo llvm-cov \ + --locked \ + --all-targets \ + --all-features \ + --json \ + --output-path "$report" + +covered_files="$(jq --arg source_root "$source_root" ' + [ + .data[].files[] + | select(.filename | startswith($source_root)) + | select(.summary.lines.count > 0) + ] + | length +' "$report")" + +if [[ "$covered_files" -eq 0 ]]; then + echo "coverage report contains no files with executable lines under src/" >&2 + exit 1 +fi + +summary="$(jq -r --arg workspace_root "$workspace_root" --arg source_root "$source_root" ' + .data[].files[] + | select(.filename | startswith($source_root)) + | select(.summary.lines.count > 0) + | [ + (.filename | ltrimstr($workspace_root)), + (.summary.lines.percent | tostring), + (.summary.lines.covered | tostring), + (.summary.lines.count | tostring) + ] + | @tsv +' "$report")" + +printf 'File\tLine coverage\tCovered lines\tCoverable lines\n' +while IFS=$'\t' read -r file percent covered count; do + printf '%s\t%.2f%%\t%s\t%s\n' "$file" "$percent" "$covered" "$count" +done <<< "$summary" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + printf '### Per-file line coverage\n\n' + printf '| File | Coverage | Lines |\n' + printf '| --- | ---: | ---: |\n' + while IFS=$'\t' read -r file percent covered count; do + printf '| %s | %.2f%% | %s/%s |\n' "$file" "$percent" "$covered" "$count" + done <<< "$summary" + } >> "$GITHUB_STEP_SUMMARY" +fi + +failures="$(jq -r \ + --arg workspace_root "$workspace_root" \ + --arg source_root "$source_root" \ + --argjson minimum "$minimum" ' + .data[].files[] + | select(.filename | startswith($source_root)) + | select(.summary.lines.count > 0) + | select(.summary.lines.percent < $minimum) + | "\(.filename | ltrimstr($workspace_root)): \(.summary.lines.percent)%" + ' "$report")" + +if [[ -n "$failures" ]]; then + printf '\nFiles below %s%% line coverage:\n%s\n' "$minimum" "$failures" >&2 + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62625b2..a8b8c72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: with: components: rustfmt, clippy + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: Swatinem/rust-cache@v2 - name: Check formatting @@ -49,6 +51,38 @@ jobs: - name: Test default features run: cargo test + - 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 + with: + name: coverage-json + path: coverage.json + if-no-files-found: ignore + + module-e2e: + name: TinyBus module E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build the loadable module + run: cargo build --locked --release --package tinydocs-module + + - name: Load the module and call GenerateDocx + env: + TINYDOCS_TEST_MODULE: ${{ github.workspace }}/target/release/libtinydocs_module.so + run: cargo test --locked --package tinydocs-module --test module_e2e -- --ignored + docs: name: Docs runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c60ed4..d9efe52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,11 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest environment: Production + outputs: + crate_name: ${{ steps.version.outputs.crate_name }} + next_version: ${{ steps.version.outputs.next_version }} + tag: ${{ steps.version.outputs.tag }} + tinybus_version: ${{ steps.version.outputs.tinybus_version }} steps: - uses: actions/checkout@v7 with: @@ -35,6 +40,8 @@ jobs: with: components: rustfmt, clippy + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: Swatinem/rust-cache@v2 - name: Check formatting @@ -46,6 +53,9 @@ jobs: - name: Test run: cargo test --all-features + - name: Require 90% line coverage in every source file + run: .github/scripts/check-file-coverage.sh 90 coverage.json + - name: Build documentation env: RUSTDOCFLAGS: -D warnings @@ -58,12 +68,23 @@ 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="tinydocs" + current_version="$(jq -r '.packages[] | select(.name == "tinydocs") | .version' <<< "$metadata")" + tinybus_version="$( + cargo metadata \ + --manifest-path vendor/tinybus/Cargo.toml \ + --format-version 1 \ + --no-deps \ + | jq -r '.packages[] | select(.name == "tinybus") | .version' + )" if [[ -z "$current_version" || "$current_version" == "null" ]]; then echo "Could not resolve the current crate version" >&2 exit 1 fi + if [[ -z "$tinybus_version" || "$tinybus_version" == "null" ]]; then + echo "Could not resolve the TinyBus version" >&2 + exit 1 + fi IFS=. read -r major minor patch <<< "$current_version" case "${{ inputs.bump }}" in @@ -99,6 +120,7 @@ jobs: echo "current_version=${current_version}" echo "next_version=${next_version}" echo "tag=${tag}" + echo "tinybus_version=${tinybus_version}" } >> "$GITHUB_OUTPUT" - name: Update crate version @@ -108,6 +130,7 @@ jobs: run: | set -euo pipefail perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml + perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' crates/tinydocs-module/Cargo.toml cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" - name: Commit version bump and tag @@ -117,12 +140,31 @@ jobs: set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock + git add Cargo.toml Cargo.lock crates/tinydocs-module/Cargo.toml git commit -m "Release ${RELEASE_TAG}" git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - name: Package crate - run: cargo package --locked + run: cargo package --locked --package tinydocs + + - name: Package TinyBus source and module SDK + run: | + set -euo pipefail + tinybus_revision="$(git -C vendor/tinybus rev-parse --short=12 HEAD)" + git -C vendor/tinybus archive \ + --format=tar.gz \ + --prefix="tinybus-${tinybus_revision}/" \ + --output="$PWD/target/package/tinybus-source-${tinybus_revision}.tar.gz" \ + HEAD + + - name: Upload source packages + uses: actions/upload-artifact@v4 + with: + name: source-packages + path: | + target/package/${{ steps.version.outputs.crate_name }}-${{ steps.version.outputs.next_version }}.crate + target/package/tinybus-source-*.tar.gz + if-no-files-found: error - name: Push release commit and tag env: @@ -133,6 +175,107 @@ jobs: git push origin "${RELEASE_TAG}" - name: Publish to crates.io - run: cargo publish --locked + run: cargo publish --locked --package tinydocs env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + native-bundles: + name: Native bundle (${{ matrix.os }}) + needs: publish + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.publish.outputs.tag }} + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + vendor/tinybus -> target + + - name: Build TinyBus host + working-directory: vendor/tinybus + run: cargo build --locked --release --package tinybus --all-features --bin tinybus + + - name: Build TinyDocs module + run: cargo build --locked --release --package tinydocs-module + + - name: Assemble native bundle + id: bundle + shell: bash + env: + TINYBUS_VERSION: ${{ needs.publish.outputs.tinybus_version }} + TINYDOCS_VERSION: ${{ needs.publish.outputs.next_version }} + run: | + set -euo pipefail + + target_triple="$(rustc -vV | sed -n 's/^host: //p')" + bundle_name="tinydocs-${TINYDOCS_VERSION}-tinybus-${TINYBUS_VERSION}-${target_triple}" + bundle_root="dist/${bundle_name}" + module_root="${bundle_root}/modules" + mkdir -p "${bundle_root}/bin" "$module_root" "${bundle_root}/docs" + + install -m 755 vendor/tinybus/target/release/tinybus "${bundle_root}/bin/tinybus" + + module_artifact="$(find target/release -maxdepth 1 -type f \( -name 'libtinydocs_module.so' -o -name 'libtinydocs_module.dylib' \) -print -quit)" + if [[ -z "$module_artifact" || ! -f "$module_artifact" ]]; then + echo "the TinyDocs module artifact is missing" >&2 + exit 1 + fi + install -m 644 "$module_artifact" "$module_root/" + + module_name="$(basename "$module_artifact")" + module_hash="$(shasum -a 256 "${module_root}/${module_name}" | awk '{print $1}')" + printf '"%s" = "%s"\n' "$module_name" "$module_hash" > "${module_root}/modules.toml" + + install -m 644 LICENSE "${bundle_root}/LICENSE" + install -m 644 README.md "${bundle_root}/README.md" + install -m 644 docs/specs/tinybus-module.md "${bundle_root}/docs/tinydocs-module.md" + install -m 644 vendor/tinybus/docs/protocol.md "${bundle_root}/docs/tinybus-protocol.md" + cp -R vendor/tinybus/docs/modules "${bundle_root}/docs/tinybus-modules" + + tar -C dist -czf "dist/${bundle_name}.tar.gz" "$bundle_name" + echo "archive=dist/${bundle_name}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Upload native bundle + uses: actions/upload-artifact@v4 + with: + name: tinydocs-${{ runner.os }} + path: ${{ steps.bundle.outputs.archive }} + if-no-files-found: error + + github-release: + name: Create GitHub release + needs: + - publish + - native-bundles + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: '*' + path: release-assets + merge-multiple: true + + - name: Create release and upload assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + REPOSITORY: ${{ github.repository }} + run: >- + gh release create "$RELEASE_TAG" release-assets/* + --repo "$REPOSITORY" + --verify-tag + --title "$RELEASE_TAG" + --generate-notes diff --git a/AGENTS.md b/AGENTS.md index 93479ff..957873b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ src/ └── test.rs # module-local unit tests tests/ # integration tests against the public API only examples/ # runnable, compiled-in-CI usage examples +crates/tinydocs-module/ # private TinyBus cdylib adapter vendor/tinybus/ # pinned TinyBus source; optional until wired by a project docs/ ├── specs/ # behavior and architecture specifications @@ -68,7 +69,7 @@ the crate-wide `Result` from fallible public APIs. ## Build And Test -Run every command from the repository root. These four are the contract; CI +Run every command from the repository root. These five are the contract; CI runs exactly them, so a green local run should mean a green CI run. ```sh @@ -76,6 +77,7 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features +.github/scripts/check-file-coverage.sh 90 coverage.json ``` Supporting commands: @@ -86,6 +88,8 @@ Supporting commands: - `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. +- `cargo install cargo-llvm-cov` — install the coverage tool required by the + per-file coverage gate. Never skip, ignore, or delete a failing test to make a command pass. Fix the root cause, or stop and report the blocker. @@ -167,9 +171,9 @@ on every generated crate. - Tests must be deterministic and independent of network, wall-clock time, and execution order. Gate any live/network test behind a feature or an env var and name it `live_*` so it is easy to exclude. -- Maintain at least 80% coverage of meaningful library behavior. Add or update - tests with every behavior change, and note any deliberately untested edge case - in the pull request description. +- Maintain at least 90% line coverage in every source file under `src/`. Add or + update tests with every behavior change, and note any deliberately untested + edge case in the pull request description. Write the test first when fixing a bug: a failing test that reproduces the report, then the fix that turns it green. diff --git a/Cargo.lock b/Cargo.lock index 803afec..5783deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -53,6 +64,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" @@ -268,6 +285,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.18.1" @@ -432,6 +461,40 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "tinybus" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tinybus-macros", + "tokio", + "tracing", +] + +[[package]] +name = "tinybus-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-module" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tokio", + "tracing", +] + [[package]] name = "tinydocs" version = "0.1.0" @@ -443,6 +506,69 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "tinydocs-module" +version = "0.1.0" +dependencies = [ + "tinybus", + "tinybus-module", + "tinydocs", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typed-path" version = "0.12.3" diff --git a/Cargo.toml b/Cargo.toml index 01f77b3..7ce60b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,12 @@ exclude = [ "deny.toml", ] +[workspace] +members = ["crates/tinydocs-module"] +default-members = [".", "crates/tinydocs-module"] +exclude = ["vendor/tinybus"] +resolver = "3" + [dependencies] # 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. @@ -55,14 +61,14 @@ docx = ["dep:docx-rs"] # 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] +[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. @@ -79,10 +85,13 @@ 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" +[lints] +workspace = true + [profile.release] # Cross-crate optimization and smaller, faster binaries for release builds. lto = "thin" diff --git a/README.md b/README.md index b0292c1..f8bca37 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,38 @@ multi-hundred-megabyte document in memory. `DocumentSpec::validate` is public and runs before any synthesis, so a host can reject a bad tool call at its own boundary without paying for a blocking hop. +## TinyBus module + +The private `tinydocs-module` workspace crate builds TinyDocs as a trusted +in-process TinyBus module while keeping the published library bus-agnostic: + +```sh +cargo build --release --package tinydocs-module +``` + +The native artifact is `target/release/libtinydocs_module.so` on Linux, +`libtinydocs_module.dylib` on macOS, or `tinydocs_module.dll` on Windows. Load +it with a TinyBus host built with its `modules` feature. It claims +`ai.tinyhumans.tinydocs.Docx` at `/ai/tinyhumans/tinydocs/Docx` and exposes: + +```text +GenerateDocx(DocumentSpec) -> Vec +``` + +The release workflow attaches installable Linux and macOS bundles containing +the matching TinyBus host, the TinyDocs module, a SHA-256 `modules.toml` +allowlist, and protocol/module documentation. It also attaches the published +crate and pinned TinyBus source. TinyBus modules are target-specific and +trusted: download the bundle matching the host, and install it only from a +trusted release. + +Run the real loader test locally after building the release artifact: + +```sh +TINYDOCS_TEST_MODULE="$PWD/target/release/libtinydocs_module.so" \ + cargo test --package tinydocs-module --test module_e2e -- --ignored +``` + ## Feature flags | Feature | Default | Gates | @@ -82,12 +114,14 @@ src/ ├── error/ │ ├── mod.rs # crate-wide `Error` and `Result` │ └── test.rs -└── docx/ +├── docx/ ├── mod.rs # `generate` + spec validation ├── types.rs # `DocumentSpec`, `DocumentSection`, limits └── test.rs tests/ └── public_api.rs # integration tests against the public API only +crates/ +└── tinydocs-module/ # private TinyBus cdylib adapter + loader E2E test examples/ └── basic.rs # compiled and linted in CI ``` @@ -101,6 +135,7 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo test --all-features cargo run --example basic +.github/scripts/check-file-coverage.sh 90 coverage.json ``` Run the gated build too — it is the only thing that catches a feature that diff --git a/ROADMAP.md b/ROADMAP.md index 3c2032d..7d77c01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,11 +12,16 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts. - lint configuration in `[lints]`, enforced identically locally and in CI - CI: format, clippy, build, test, rustdoc, MSRV, and supply-chain checks - a manual release workflow that versions, tags, and publishes to crates.io +- a TinyBus-loadable native module exposing DOCX generation +- installable Linux and macOS bundles with the TinyBus host, module allowlist, + source packages, and protocol documentation on GitHub releases +- end-to-end coverage through TinyBus's real dynamic loader and broker ## Next -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow +- path or file-descriptor transfer for document formats that outgrow the bus + frame limit +- additional document formats behind focused feature flags ## Out Of Scope diff --git a/crates/tinydocs-module/Cargo.toml b/crates/tinydocs-module/Cargo.toml new file mode 100644 index 0000000..41c0441 --- /dev/null +++ b/crates/tinydocs-module/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tinydocs-module" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "Trusted TinyBus module adapter for TinyDocs." +repository = "https://github.com/tinyhumansai/tinydocs" +publish = false + +[lib] +crate-type = ["rlib", "cdylib"] + +[dependencies] +# The pure document library remains independently publishable and bus-agnostic. +tinydocs = { path = "../..", default-features = false, features = ["docx"] } +# TinyBus provides the typed service interface and dynamic module host ABI. +tinybus = { version = "0.1.0", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros", "modules"] } +# The module-side SDK owns its runtime and exports the stable C entrypoints. +tinybus-module = { version = "0.1.0", path = "../../vendor/tinybus/crates/tinybus-module" } +# Module methods move CPU-bound document synthesis onto Tokio's blocking pool. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/tinydocs-module/src/lib.rs b/crates/tinydocs-module/src/lib.rs new file mode 100644 index 0000000..86d9943 --- /dev/null +++ b/crates/tinydocs-module/src/lib.rs @@ -0,0 +1,9 @@ +//! Loadable `TinyBus` module adapter for `TinyDocs`. +//! +//! This private workspace crate keeps the vendored `TinyBus` dependency out of +//! the independently published `tinydocs` crate. Its `cdylib` output is the +//! target-specific binary distributed in GitHub releases. + +mod service; + +pub use service::{BUS_NAME, OBJECT_PATH}; diff --git a/crates/tinydocs-module/src/service/mod.rs b/crates/tinydocs-module/src/service/mod.rs new file mode 100644 index 0000000..5a36969 --- /dev/null +++ b/crates/tinydocs-module/src/service/mod.rs @@ -0,0 +1,84 @@ +//! `TinyBus` service boundary for document synthesis. +//! +//! The module owns no persistent state and exposes one object: `GenerateDocx` +//! accepts the same typed [`DocumentSpec`] as the Rust API and returns the +//! complete DOCX bytes. +//! +//! The `TinyBus` wire format has a 16 MiB frame limit. [`DocumentSpec`]'s +//! aggregate text limit keeps normal output comfortably below that boundary; +//! a larger future document format should use a path or file-descriptor based +//! transfer instead of increasing the bus frame cap. + +use tinybus::{Connection, Error as BusError, Result as BusResult}; +use tinydocs::Error; +use tinydocs::docx::{self, DocumentSpec}; + +/// Well-known name and interface exported by the `TinyDocs` module. +pub const BUS_NAME: &str = "ai.tinyhumans.tinydocs.Docx"; + +/// Object path exported by the `TinyDocs` module. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinydocs/Docx"; + +const INVALID_INPUT_ERROR: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput"; +const GENERATION_FAILED_ERROR: &str = "ai.tinyhumans.tinydocs.Error.GenerationFailed"; +const MODULE_FAILED_ERROR: &str = "ai.tinyhumans.tinydocs.Error.ModuleFailed"; + +struct TinyDocs; + +#[tinybus::interface(name = "ai.tinyhumans.tinydocs.Docx")] +impl TinyDocs { + /// Generate a complete DOCX document from a validated specification. + async fn generate_docx(&self, spec: DocumentSpec) -> BusResult> { + tokio::task::spawn_blocking(move || docx::generate(&spec)) + .await + .map_err(|_| BusError::MethodFailed { + name: GENERATION_FAILED_ERROR.to_string(), + message: "document generation worker failed".to_string(), + })? + .map_err(|error| map_error(&error)) + } +} + +fn map_error(error: &Error) -> BusError { + let name = match error { + Error::InvalidInput { .. } => INVALID_INPUT_ERROR, + Error::GenerationFailed { .. } => GENERATION_FAILED_ERROR, + _ => MODULE_FAILED_ERROR, + }; + BusError::MethodFailed { + name: name.to_string(), + message: error.to_string(), + } +} + +async fn setup(connection: Connection) -> BusResult<()> { + connection + .serve_at(OBJECT_PATH.try_into()?, TinyDocs) + .await?; + connection.request_name(BUS_NAME).await?; + Ok(()) +} + +// Isolate the three generated public C symbols so the lint exception cannot +// hide undocumented Rust API. Their contract is TinyBus ABI v1, and none is a +// Rust-callable export from this private module. +#[allow( + missing_docs, + unreachable_pub, + reason = "generated C ABI symbols are documented by the TinyBus module SDK" +)] +mod exports { + tinybus_module::module_export! { + setup = super::setup, + worker_threads = 2, + provides = ["ai.tinyhumans.tinydocs.Docx"], + methods = ["GenerateDocx"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinydocs-module/src/service/test.rs b/crates/tinydocs-module/src/service/test.rs new file mode 100644 index 0000000..327ee4c --- /dev/null +++ b/crates/tinydocs-module/src/service/test.rs @@ -0,0 +1,30 @@ +//! Unit tests for the `TinyBus` service declaration. + +#![allow(clippy::unwrap_used)] + +use tinybus::Interface; + +use super::*; + +#[test] +fn service_identity_is_valid_and_dispatch_matches_the_manifest() { + assert!(tinybus::BusName::new(BUS_NAME).is_ok()); + assert!(tinybus::ObjectPath::new(OBJECT_PATH).is_ok()); + + let members = TinyDocs.members(); + assert_eq!( + members, + &[tinybus::MemberName::new("GenerateDocx").unwrap()] + ); +} + +#[test] +fn domain_errors_keep_distinct_wire_names() { + let invalid_error = Error::invalid_input("title", "must not be empty"); + let invalid = map_error(&invalid_error); + assert_eq!(invalid.wire_name(), INVALID_INPUT_ERROR); + + let generation_error = Error::generation_failed("writer stopped"); + let failed = map_error(&generation_error); + assert_eq!(failed.wire_name(), GENERATION_FAILED_ERROR); +} diff --git a/crates/tinydocs-module/tests/module_e2e.rs b/crates/tinydocs-module/tests/module_e2e.rs new file mode 100644 index 0000000..f346bc9 --- /dev/null +++ b/crates/tinydocs-module/tests/module_e2e.rs @@ -0,0 +1,66 @@ +//! End-to-end test for loading the built `TinyDocs` module into `TinyBus`. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::time::Duration; + +use tinybus::Connection; +use tinybus::broker::Broker; +use tinybus::module::{ModuleHost, ModuleState}; +use tinybus::transport::memory::MemoryBus; +use tinydocs::docx::{DocumentSection, DocumentSpec}; +use tinydocs_module::{BUS_NAME, OBJECT_PATH}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYDOCS_TEST_MODULE to point at the built cdylib"] +async fn built_cdylib_loads_and_generates_a_docx_over_the_bus() { + let artifact = + std::env::var_os("TINYDOCS_TEST_MODULE").expect("TINYDOCS_TEST_MODULE must be set"); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + let loaded = modules.load_file(artifact).expect("module should load"); + assert_eq!(loaded.name, "tinydocs-module"); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if client + .list_names() + .await + .unwrap() + .iter() + .any(|name| name.as_str() == BUS_NAME) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("module should become ready"); + + let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); + let bytes: Vec = proxy + .call( + "GenerateDocx", + (DocumentSpec { + title: "TinyBus E2E".to_string(), + author: Some("TinyDocs".to_string()), + sections: vec![DocumentSection { + heading: Some("Loaded module".to_string()), + paragraphs: vec!["Generated through the real module ABI.".to_string()], + bullets: vec!["valid DOCX".to_string()], + }], + },), + ) + .await + .expect("bus call should succeed"); + + assert_eq!(&bytes[..2], b"PK"); + assert!(matches!(modules.list()[0].state, ModuleState::Ready)); + broker_task.abort(); +} diff --git a/docs/plans/tinybus-module.md b/docs/plans/tinybus-module.md new file mode 100644 index 0000000..b37b3c9 --- /dev/null +++ b/docs/plans/tinybus-module.md @@ -0,0 +1,34 @@ +# TinyBus module implementation plan + +Specification: [`../specs/tinybus-module.md`](../specs/tinybus-module.md) + +Goal: ship TinyDocs as a tested TinyBus dynamic module without changing the +default library dependency graph. + +## Completed work + +- [x] Advance `vendor/tinybus` to canonical `main` with module ABI v1. +- [x] Add a private adapter crate with TinyBus SDK, host, macro, and runtime + dependencies so `tinydocs` remains publishable. +- [x] Emit both `rlib` and `cdylib` adapter crate types. +- [x] Add the typed DOCX service and stable bus identity. +- [x] Preserve domain-specific errors at the wire boundary. +- [x] Add unit coverage for identity, dispatch, and error mapping. +- [x] Add an ignored integration test that loads the built native artifact. +- [x] Run that real-loader test in CI. +- [x] Build and upload installable Linux and macOS TinyBus bundles, source + packages, module allowlists, and documentation during release. +- [x] Document build, loading, trust, and payload constraints. + +## Verification + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +.github/scripts/check-file-coverage.sh 90 coverage.json +cargo build --locked --release --package tinydocs-module +TINYDOCS_TEST_MODULE="$PWD/target/release/libtinydocs_module.so" \ + cargo test --locked --package tinydocs-module --test module_e2e -- --ignored +``` diff --git a/docs/specs/tinybus-module.md b/docs/specs/tinybus-module.md new file mode 100644 index 0000000..4df3908 --- /dev/null +++ b/docs/specs/tinybus-module.md @@ -0,0 +1,76 @@ +# TinyBus module + +Status: Implemented + +Owner: TinyDocs maintainers + +## Problem + +TinyDocs must be installable as a compiled TinyBus module so a host can use +document generation without linking the document stack into its own binary. +The released artifact must exercise the same ABI and loading path used in +production. + +## Goals + +- Preserve the existing pure Rust library API. +- Build a target-specific dynamic library implementing TinyBus module ABI v1. +- Expose typed DOCX generation through a stable bus identity. +- Publish installable native bundles with each GitHub release. +- Test loading and calling the compiled artifact through a real broker. + +## Non-goals + +- Loading untrusted third-party modules safely. +- Stable ABI compatibility across TinyBus ABI revisions. +- Streaming or file-descriptor transfer in this first interface. +- Running TinyDocs as a separate socket process. + +## Behavior + +The private `tinydocs-module` workspace crate depends on the public library's +`docx` feature and builds as a `cdylib`. This separation keeps unpublished, +vendored TinyBus packages out of the crates.io package manifest. The module +claims `ai.tinyhumans.tinydocs.Docx`, serves the object path +`/ai/tinyhumans/tinydocs/Docx`, and exports one method: + +```text +GenerateDocx(DocumentSpec) -> Vec +``` + +The argument is the same Serde document contract used by the Rust API. A +successful response contains a complete DOCX zip container. Invalid input and +writer failures use the distinct wire names +`ai.tinyhumans.tinydocs.Error.InvalidInput` and +`ai.tinyhumans.tinydocs.Error.GenerationFailed`. + +Generation is CPU-bound and runs on the module runtime's blocking pool. The +module itself retains no document state between calls. + +## Invariants and constraints + +- The vendored TinyBus gitlink is the ABI source of truth. +- Manifest methods and generated dispatch members must remain identical. +- No Rust value crosses the dynamic-library ABI boundary. +- The native artifact must match the host target and TinyBus compatibility + gate. +- Message payloads remain subject to TinyBus's 16 MiB frame cap. A future + format that can exceed it must use path or file-descriptor transfer. +- Dynamic modules are trusted code with the host process's privileges. + +## Acceptance criteria + +- `cargo build --release --package tinydocs-module` emits the platform dynamic + library. +- TinyBus `ModuleHost` admits that artifact and reaches `ready` state. +- A proxy call to `GenerateDocx` returns bytes beginning with the DOCX `PK` + signature. +- CI executes that loader test on Linux. +- A release uploads Linux and macOS bundles containing the matching TinyBus + host, TinyDocs module, SHA-256 allowlist, and operational documentation. +- The release also uploads the crates.io package and pinned TinyBus source. + +## Open questions + +None blocking this version. Bulk transfer becomes a separate protocol change +if a future format approaches the frame cap. diff --git a/vendor/tinybus b/vendor/tinybus index ddc63e3..dd6063a 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit ddc63e3f9c6e99e0be4ef0effac4d35442711cc4 +Subproject commit dd6063afc71ed82d2c9609f83fdbab758b96c2ed