From 47f093efcfbfb9a971f8bf8bd7f463f1cf318db0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 10:58:10 +0300 Subject: [PATCH 1/2] Refactor TinyBus contract into a crate Co-authored-by: Medulla --- .github/workflows/release.yml | 18 +- AGENTS.md | 12 +- Cargo.lock | 11 +- Cargo.toml | 14 +- README.md | 18 +- crates/tinydocs-bus/Cargo.toml | 25 +++ crates/tinydocs-bus/README.md | 16 ++ crates/tinydocs-bus/src/docx.rs | 237 +++++++++++++++++++++ crates/tinydocs-bus/src/error.rs | 93 ++++++++ crates/tinydocs-bus/src/lib.rs | 19 ++ crates/tinydocs-bus/src/names.rs | 16 ++ crates/tinydocs-bus/src/version.rs | 13 ++ crates/tinydocs-module/Cargo.toml | 2 + crates/tinydocs-module/src/lib.rs | 2 +- crates/tinydocs-module/src/service/mod.rs | 9 +- crates/tinydocs-module/src/service/test.rs | 10 +- crates/tinydocs-module/tests/module_e2e.rs | 11 +- docs/specs/tinybus-module.md | 14 +- src/docx/mod.rs | 122 +---------- src/docx/test.rs | 23 -- src/docx/types.rs | 134 ------------ src/error/mod.rs | 95 --------- src/error/test.rs | 54 ----- src/lib.rs | 8 +- tests/public_api.rs | 7 + 25 files changed, 506 insertions(+), 477 deletions(-) create mode 100644 crates/tinydocs-bus/Cargo.toml create mode 100644 crates/tinydocs-bus/README.md create mode 100644 crates/tinydocs-bus/src/docx.rs create mode 100644 crates/tinydocs-bus/src/error.rs create mode 100644 crates/tinydocs-bus/src/lib.rs create mode 100644 crates/tinydocs-bus/src/names.rs create mode 100644 crates/tinydocs-bus/src/version.rs delete mode 100644 src/docx/types.rs delete mode 100644 src/error/mod.rs delete mode 100644 src/error/test.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a551121..d22005e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,9 @@ 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-bus/Cargo.toml perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' crates/tinydocs-module/Cargo.toml + cargo update -p tinydocs-bus --precise "$NEXT_VERSION" cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" - name: Commit version bump and tag @@ -140,12 +142,21 @@ 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 crates/tinydocs-module/Cargo.toml + git add Cargo.toml Cargo.lock crates/tinydocs-bus/Cargo.toml 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 --package tinydocs + - name: Package and publish the bus contract + run: | + set -euo pipefail + cargo package --locked --package tinydocs-bus + cargo publish --locked --package tinydocs-bus + + - name: Package and publish crate + run: | + set -euo pipefail + cargo package --locked --package tinydocs + cargo publish --locked --package tinydocs - name: Package TinyBus source and module SDK run: | @@ -162,6 +173,7 @@ jobs: with: name: source-packages path: | + target/package/tinydocs-bus-${{ steps.version.outputs.next_version }}.crate target/package/${{ steps.version.outputs.crate_name }}-${{ steps.version.outputs.next_version }}.crate target/package/tinybus-source-*.tar.gz if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 957873b..0a58a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,13 +32,13 @@ This is a Rust 2024 library crate rooted at `Cargo.toml`. ```text src/ ├── lib.rs # crate docs + the entire public re-export surface -├── error/mod.rs # crate-wide `Error` and `Result` └── / # 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 +crates/tinydocs-bus/ # TinyBus wire contract: names, payload types, errors crates/tinydocs-module/ # private TinyBus cdylib adapter vendor/tinybus/ # pinned TinyBus source; optional until wired by a project docs/ @@ -64,8 +64,9 @@ 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. +predictable surface. The shared error variants live in `tinydocs-bus` because +`DocumentSpec::validate` is an inherent method on a contract-owned type; re- +export its `Error` and `Result` from `src/lib.rs` for the primary API. ## Build And Test @@ -114,8 +115,9 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around ### Errors -- One crate-wide `Error` enum in `src/error/mod.rs`, built with `thiserror`. -- Fallible public functions return `Result`, the crate alias. +- One crate-wide `Error` enum in `crates/tinydocs-bus/src/error.rs`, built with + `thiserror` and re-exported by `tinydocs`. +- Fallible public functions return `Result`, the re-exported crate alias. - Add a specific variant instead of stuffing context into a string; error messages are lowercase, without trailing punctuation. - Do not `unwrap()`, `expect()`, or `panic!` in library code paths. They are diff --git a/Cargo.lock b/Cargo.lock index 6c0719b..ebe3480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,10 +723,18 @@ name = "tinydocs" version = "0.1.9" dependencies = [ "docx-rs", + "thiserror", + "tinydocs-bus", + "zip 2.4.2", +] + +[[package]] +name = "tinydocs-bus" +version = "0.1.9" +dependencies = [ "serde", "serde_json", "thiserror", - "zip 2.4.2", ] [[package]] @@ -736,6 +744,7 @@ dependencies = [ "tinybus", "tinybus-module", "tinydocs", + "tinydocs-bus", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 9939f13..059ad03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,8 @@ exclude = [ ] [workspace] -members = ["crates/tinydocs-module"] -default-members = [".", "crates/tinydocs-module"] +members = ["crates/tinydocs-bus", "crates/tinydocs-module"] +default-members = [".", "crates/tinydocs-bus", "crates/tinydocs-module"] exclude = ["vendor/tinybus"] resolver = "3" @@ -31,10 +31,9 @@ resolver = "3" # 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. thiserror = "2" -# The document spec types are the wire contract a host exposes to an LLM as a -# JSON tool schema, so they derive Serialize/Deserialize here rather than -# forcing every host to re-declare them. -serde = { version = "1", features = ["derive"] } +# The dependency-light TinyBus contract owns document payload types. Re-export +# it so existing `tinydocs::docx` callers keep using the exact same types. +tinydocs-bus = { version = "0.1.9", path = "crates/tinydocs-bus" } # OOXML `.docx` synthesis. Optional: exclusive to the `docx` feature so a host # that only needs extraction does not pull the writer stack. docx-rs = { version = "0.4.20", optional = true } @@ -43,9 +42,6 @@ docx-rs = { version = "0.4.20", optional = true } # `.docx` output is a zip container; the tests re-open the produced bytes and # assert on the OOXML parts inside. zip = { version = "2", default-features = false, features = ["deflate"] } -# The spec types are a JSON wire contract; the tests assert they round-trip and -# that unknown keys are rejected. -serde_json = "1" # The example generates a `.docx`, so it only builds when that gate is on. # Without this, `--no-default-features` fails on the example rather than diff --git a/README.md b/README.md index 13dcf54..87ac057 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,13 @@ reject a bad tool call at its own boundary without paying for a blocking hop. ## TinyBus module +`tinydocs-bus` is the dependency-light contract crate for TinyBus hosts. It +contains the bus identity, member names, versioning, and `DocumentSpec` types, +but no TinyBus transport or document writer. `tinydocs` re-exports the same +types, so existing `tinydocs::docx` callers remain source-compatible. + The private `tinydocs-module` workspace crate builds TinyDocs as a trusted -in-process TinyBus module while keeping the published library bus-agnostic: +in-process TinyBus module: ```sh cargo build --release --package tinydocs-module @@ -116,17 +121,14 @@ TINYDOCS_TEST_MODULE="$PWD/target/release/libtinydocs_module.so" \ ```text src/ -├── lib.rs # crate docs + the entire public re-export surface -├── error/ -│ ├── mod.rs # crate-wide `Error` and `Result` -│ └── test.rs -├── docx/ - ├── mod.rs # `generate` + spec validation - ├── types.rs # `DocumentSpec`, `DocumentSection`, limits +├── lib.rs # crate docs + the public re-export surface +└── docx/ + ├── mod.rs # `generate` + spec validation + contract re-exports └── test.rs tests/ └── public_api.rs # integration tests against the public API only crates/ +├── tinydocs-bus/ # TinyBus names, version, and document payload contract └── tinydocs-module/ # private TinyBus cdylib adapter + loader E2E test examples/ └── basic.rs # compiled and linted in CI diff --git a/crates/tinydocs-bus/Cargo.toml b/crates/tinydocs-bus/Cargo.toml new file mode 100644 index 0000000..591c8fb --- /dev/null +++ b/crates/tinydocs-bus/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tinydocs-bus" +version = "0.1.9" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "TinyBus wire contract for TinyDocs: names and document payload types." +repository = "https://github.com/tinyhumansai/tinydocs" +documentation = "https://docs.rs/tinydocs-bus" +readme = "README.md" +keywords = ["docx", "tinybus", "document", "agent"] +categories = ["data-structures"] + +[dependencies] +# Document specifications cross the TinyBus boundary as typed Serde payloads. +serde = { version = "1", features = ["derive"] } +# The contract owns structured validation and generation error variants. +thiserror = "2" + +[dev-dependencies] +# Contract tests ensure the JSON vocabulary stays compatible for bus hosts. +serde_json = "1" + +[lints] +workspace = true diff --git a/crates/tinydocs-bus/README.md b/crates/tinydocs-bus/README.md new file mode 100644 index 0000000..c7cae38 --- /dev/null +++ b/crates/tinydocs-bus/README.md @@ -0,0 +1,16 @@ +# tinydocs-bus + +The transport-free TinyBus contract for TinyDocs: its well-known bus identity, +member names, contract version, and the document payload types that cross the +bus boundary. + +`tinydocs-bus` deliberately does not depend on `tinybus`, an async runtime, or +the OOXML writer. A host that only calls the module can depend on this crate to +construct `DocumentSpec` values without compiling the document implementation +or the loadable module. `tinydocs` depends on and re-exports the same types, so +`tinydocs::docx::DocumentSpec` and `tinydocs_bus::docx::DocumentSpec` are one +type, not compatible-looking duplicates. + +The module serves `GenerateDocx(DocumentSpec) -> Vec` at [`BUS_NAME`] and +[`OBJECT_PATH`]. Keep changes here backward compatible or advance +[`CONTRACT_VERSION`] according to the documented compatibility rule. diff --git a/crates/tinydocs-bus/src/docx.rs b/crates/tinydocs-bus/src/docx.rs new file mode 100644 index 0000000..39ff0c3 --- /dev/null +++ b/crates/tinydocs-bus/src/docx.rs @@ -0,0 +1,237 @@ +//! Typed payloads carried by the `TinyDocs` `GenerateDocx` bus member. +//! +//! The limits are part of the contract so hosts can describe and preflight the +//! same payload bounds enforced by `tinydocs` before it starts generation. + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Maximum number of sections a single document may contain. +pub const MAX_SECTIONS: usize = 128; + +/// Maximum length, in Unicode scalar values, of a title, author, or heading. +pub const MAX_TEXT_CHARS: usize = 2_000; + +/// Maximum length, in Unicode scalar values, of one paragraph or bullet. +pub const MAX_PARAGRAPH_CHARS: usize = 20_000; + +/// Maximum number of body paragraphs in a single section. +pub const MAX_PARAGRAPHS_PER_SECTION: usize = 200; + +/// Maximum number of bullet-list items in a single section. +pub const MAX_BULLETS_PER_SECTION: usize = 200; + +/// Aggregate cap on all renderable text in one document, in Unicode scalars. +pub const MAX_TOTAL_CHARS: usize = 2_000_000; + +/// One document section, rendered in spec order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DocumentSection { + /// Optional section heading. + #[serde(default)] + pub heading: Option, + /// Body paragraphs in display order. + #[serde(default)] + pub paragraphs: Vec, + /// Bullet items in display order. + #[serde(default)] + pub bullets: Vec, +} + +impl DocumentSection { + /// Returns whether this section has no renderable heading, paragraph, or bullet. + #[must_use] + pub fn is_blank(&self) -> bool { + let has_heading = self + .heading + .as_deref() + .is_some_and(|heading| !heading.trim().is_empty()); + let has_paragraph = self + .paragraphs + .iter() + .any(|paragraph| !paragraph.trim().is_empty()); + let has_bullet = self.bullets.iter().any(|bullet| !bullet.trim().is_empty()); + !(has_heading || has_paragraph || has_bullet) + } +} + +/// A complete `.docx` document specification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DocumentSpec { + /// Required document title. + pub title: String, + /// Optional author byline. + #[serde(default)] + pub author: Option, + /// Document sections in display order. + #[serde(default)] + pub sections: Vec, +} + +impl DocumentSpec { + /// Checks this specification against every documented size limit. + /// + /// # Errors + /// + /// Returns [`Error::InvalidInput`] naming the first field that violates a + /// limit. Fields are checked in spec order for a stable result. + pub fn validate(&self) -> Result<()> { + if self.title.trim().is_empty() { + return Err(Error::invalid_input("title", "must not be empty")); + } + if self.title.chars().count() > MAX_TEXT_CHARS { + return Err(Error::invalid_input( + "title", + format!("must be ≤ {MAX_TEXT_CHARS} chars"), + )); + } + let over_budget = || { + Error::invalid_input( + "sections", + format!("total document text must be ≤ {MAX_TOTAL_CHARS} chars"), + ) + }; + let mut total = self.title.chars().count(); + if let Some(author) = self.author.as_deref() { + if author.chars().count() > MAX_TEXT_CHARS { + return Err(Error::invalid_input( + "author", + format!("must be ≤ {MAX_TEXT_CHARS} chars"), + )); + } + total = total.saturating_add(author.chars().count()); + } + if self.sections.is_empty() { + return Err(Error::invalid_input( + "sections", + "must contain at least one section", + )); + } + if self.sections.len() > MAX_SECTIONS { + return Err(Error::invalid_input( + "sections", + format!("must contain ≤ {MAX_SECTIONS} sections"), + )); + } + for (section_index, section) in self.sections.iter().enumerate() { + if section.is_blank() { + return Err(Error::invalid_input( + format!("sections[{section_index}]"), + "must have at least one of heading / paragraphs / bullets", + )); + } + if let Some(heading) = section.heading.as_deref() { + if heading.chars().count() > MAX_TEXT_CHARS { + return Err(Error::invalid_input( + format!("sections[{section_index}].heading"), + format!("must be ≤ {MAX_TEXT_CHARS} chars"), + )); + } + total = total.saturating_add(heading.chars().count()); + if total > MAX_TOTAL_CHARS { + return Err(over_budget()); + } + } + if section.paragraphs.len() > MAX_PARAGRAPHS_PER_SECTION { + return Err(Error::invalid_input( + format!("sections[{section_index}].paragraphs"), + format!("must contain ≤ {MAX_PARAGRAPHS_PER_SECTION} paragraphs"), + )); + } + for (paragraph_index, paragraph) in section.paragraphs.iter().enumerate() { + if paragraph.chars().count() > MAX_PARAGRAPH_CHARS { + return Err(Error::invalid_input( + format!("sections[{section_index}].paragraphs[{paragraph_index}]"), + format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), + )); + } + total = total.saturating_add(paragraph.chars().count()); + if total > MAX_TOTAL_CHARS { + return Err(over_budget()); + } + } + if section.bullets.len() > MAX_BULLETS_PER_SECTION { + return Err(Error::invalid_input( + format!("sections[{section_index}].bullets"), + format!("must contain ≤ {MAX_BULLETS_PER_SECTION} bullets"), + )); + } + for (bullet_index, bullet) in section.bullets.iter().enumerate() { + if bullet.chars().count() > MAX_PARAGRAPH_CHARS { + return Err(Error::invalid_input( + format!("sections[{section_index}].bullets[{bullet_index}]"), + format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), + )); + } + total = total.saturating_add(bullet.chars().count()); + if total > MAX_TOTAL_CHARS { + return Err(over_budget()); + } + } + } + Ok(()) + } + + /// Returns all renderable text length, in Unicode scalar values. + #[must_use] + pub fn total_chars(&self) -> usize { + let mut total = self.title.chars().count(); + if let Some(author) = self.author.as_deref() { + total = total.saturating_add(author.chars().count()); + } + for section in &self.sections { + if let Some(heading) = section.heading.as_deref() { + total = total.saturating_add(heading.chars().count()); + } + for paragraph in §ion.paragraphs { + total = total.saturating_add(paragraph.chars().count()); + } + for bullet in §ion.bullets { + total = total.saturating_add(bullet.chars().count()); + } + } + total + } +} + +#[cfg(test)] +mod test { + //! Tests for `TinyDocs` document payload compatibility. + + #![allow(clippy::expect_used)] + + use super::{DocumentSection, DocumentSpec}; + + #[test] + fn specification_round_trips_through_json() { + let spec = DocumentSpec { + title: "Contract".to_string(), + author: Some("TinyDocs".to_string()), + sections: vec![DocumentSection { + heading: Some("Payload".to_string()), + paragraphs: vec!["Shared with hosts.".to_string()], + bullets: vec![], + }], + }; + let json = serde_json::to_string(&spec).expect("spec serializes"); + let parsed: DocumentSpec = serde_json::from_str(&json).expect("spec deserializes"); + assert_eq!(parsed, spec); + } + + #[test] + fn specification_rejects_unknown_json_fields() { + let json = r#"{"title":"T","sections":[],"titel":"typo"}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn specification_defaults_optional_fields() { + let spec: DocumentSpec = + serde_json::from_str(r#"{"title":"T"}"#).expect("spec deserializes"); + assert_eq!(spec.author, None); + assert!(spec.sections.is_empty()); + } +} diff --git a/crates/tinydocs-bus/src/error.rs b/crates/tinydocs-bus/src/error.rs new file mode 100644 index 0000000..ddb16e0 --- /dev/null +++ b/crates/tinydocs-bus/src/error.rs @@ -0,0 +1,93 @@ +//! Structured errors shared by `TinyDocs` and its bus hosts. + +/// Errors returned while validating or generating a document. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// A document specification failed validation before generation began. + #[error("invalid input for field '{field}': {reason}")] + InvalidInput { + /// Path of the offending field within the specification. + field: String, + /// The violated constraint. + reason: String, + }, + + /// The OOXML writer failed to synthesize the requested document. + #[error("document generation failed: {detail}")] + GenerationFailed { + /// Truncated writer error detail. + detail: String, + }, +} + +impl Error { + /// Maximum length, in Unicode scalar values, of a generation error detail. + pub const MAX_DETAIL_CHARS: usize = 500; + + /// Suffix appended when a generation error detail is truncated. + const TRUNCATION_SUFFIX: &'static str = " […truncated]"; + + /// Builds a generation error with `raw` truncated at a UTF-8 boundary. + #[must_use] + pub fn generation_failed(raw: &str) -> Self { + Self::GenerationFailed { + detail: Self::truncate_detail(raw), + } + } + + /// Truncates `raw` to the bounded generation-error detail length. + #[must_use] + pub fn truncate_detail(raw: &str) -> String { + if raw.chars().count() <= Self::MAX_DETAIL_CHARS { + return raw.to_string(); + } + let keep = Self::MAX_DETAIL_CHARS.saturating_sub(Self::TRUNCATION_SUFFIX.chars().count()); + let mut out: String = raw.chars().take(keep).collect(); + out.push_str(Self::TRUNCATION_SUFFIX); + out + } + + /// Builds an invalid-input error for `field` violating `reason`. + #[must_use] + pub fn invalid_input(field: impl Into, reason: impl Into) -> Self { + Self::InvalidInput { + field: field.into(), + reason: reason.into(), + } + } +} + +/// Standard result type returned by fallible `TinyDocs` APIs. +pub type Result = std::result::Result; + +#[cfg(test)] +mod test { + //! Tests for the `TinyDocs` error contract. + + #![allow(clippy::panic)] + + use super::Error; + + #[test] + fn long_details_are_truncated_without_splitting_utf8() { + let raw = "🦀".repeat(Error::MAX_DETAIL_CHARS * 2); + let Error::GenerationFailed { detail } = Error::generation_failed(&raw) else { + panic!("expected GenerationFailed"); + }; + assert_eq!(detail.chars().count(), Error::MAX_DETAIL_CHARS); + assert!(detail.ends_with("[…truncated]")); + } + + #[test] + fn invalid_input_preserves_the_field_path() { + let error = Error::invalid_input("sections[2].bullets[0]", "must be ≤ 10 chars"); + assert_eq!( + error, + Error::InvalidInput { + field: "sections[2].bullets[0]".to_string(), + reason: "must be ≤ 10 chars".to_string(), + } + ); + } +} diff --git a/crates/tinydocs-bus/src/lib.rs b/crates/tinydocs-bus/src/lib.rs new file mode 100644 index 0000000..aa9d2de --- /dev/null +++ b/crates/tinydocs-bus/src/lib.rs @@ -0,0 +1,19 @@ +//! The transport-free `TinyBus` wire contract for `TinyDocs`. +//! +//! A `TinyBus` host loads the `tinydocs-module` dynamic library, but it cannot +//! import Rust types from that binary. This crate supplies the shared +//! vocabulary: bus identity, member names, versioning, and the serializable +//! document specification. It deliberately has no dependency on `tinybus`, an +//! async runtime, or document generation. +//! +//! `tinydocs` depends on and re-exports these types, so +//! `tinydocs::docx::DocumentSpec` and [`docx::DocumentSpec`] are identical. + +pub mod docx; +pub mod error; +pub mod names; +pub mod version; + +pub use error::{Error, Result}; +pub use names::{BUS_NAME, METHODS, OBJECT_PATH}; +pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/tinydocs-bus/src/names.rs b/crates/tinydocs-bus/src/names.rs new file mode 100644 index 0000000..1d833f8 --- /dev/null +++ b/crates/tinydocs-bus/src/names.rs @@ -0,0 +1,16 @@ +//! `TinyDocs` bus identity and member names. + +/// Well-known bus name exported by the `TinyDocs` module. +pub const BUS_NAME: &str = "ai.tinyhumans.tinydocs.Docx"; + +/// Object path served by the `TinyDocs` module. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinydocs/Docx"; + +/// One constant per method name on [`BUS_NAME`]. +pub mod methods { + /// `GenerateDocx` — generate a complete DOCX payload. + pub const GENERATE_DOCX: &str = "GenerateDocx"; +} + +/// All method names in the declaration order used by the module interface. +pub const METHODS: [&str; 1] = [methods::GENERATE_DOCX]; diff --git a/crates/tinydocs-bus/src/version.rs b/crates/tinydocs-bus/src/version.rs new file mode 100644 index 0000000..b8a18ec --- /dev/null +++ b/crates/tinydocs-bus/src/version.rs @@ -0,0 +1,13 @@ +//! Wire-contract versioning for `TinyDocs` hosts. + +/// Current `TinyDocs` wire-contract version. +pub const CONTRACT_VERSION: u32 = 1; + +/// Returns whether a host requiring `required` can bind to this contract. +/// +/// The first contract revision supports only exact-version bindings. A future +/// backward-compatible revision may widen this rule deliberately. +#[must_use] +pub const fn is_compatible(required: u32) -> bool { + required == CONTRACT_VERSION +} diff --git a/crates/tinydocs-module/Cargo.toml b/crates/tinydocs-module/Cargo.toml index 7455c8f..bf28406 100644 --- a/crates/tinydocs-module/Cargo.toml +++ b/crates/tinydocs-module/Cargo.toml @@ -14,6 +14,8 @@ crate-type = ["rlib", "cdylib"] [dependencies] # The pure document library remains independently publishable and bus-agnostic. tinydocs = { path = "../..", default-features = false, features = ["docx"] } +# The transport-free vocabulary shared with every TinyBus host. +tinydocs-bus = { path = "../tinydocs-bus" } # 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. diff --git a/crates/tinydocs-module/src/lib.rs b/crates/tinydocs-module/src/lib.rs index 86d9943..c373f26 100644 --- a/crates/tinydocs-module/src/lib.rs +++ b/crates/tinydocs-module/src/lib.rs @@ -6,4 +6,4 @@ mod service; -pub use service::{BUS_NAME, OBJECT_PATH}; +pub use tinydocs_bus::*; diff --git a/crates/tinydocs-module/src/service/mod.rs b/crates/tinydocs-module/src/service/mod.rs index 5a36969..f78c261 100644 --- a/crates/tinydocs-module/src/service/mod.rs +++ b/crates/tinydocs-module/src/service/mod.rs @@ -11,13 +11,8 @@ 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"; +use tinydocs::docx; +use tinydocs_bus::{BUS_NAME, OBJECT_PATH, docx::DocumentSpec}; const INVALID_INPUT_ERROR: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput"; const GENERATION_FAILED_ERROR: &str = "ai.tinyhumans.tinydocs.Error.GenerationFailed"; diff --git a/crates/tinydocs-module/src/service/test.rs b/crates/tinydocs-module/src/service/test.rs index 327ee4c..e76db6a 100644 --- a/crates/tinydocs-module/src/service/test.rs +++ b/crates/tinydocs-module/src/service/test.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used)] use tinybus::Interface; +use tinydocs_bus::{BUS_NAME, METHODS, OBJECT_PATH}; use super::*; @@ -12,10 +13,11 @@ fn service_identity_is_valid_and_dispatch_matches_the_manifest() { assert!(tinybus::ObjectPath::new(OBJECT_PATH).is_ok()); let members = TinyDocs.members(); - assert_eq!( - members, - &[tinybus::MemberName::new("GenerateDocx").unwrap()] - ); + let published: Vec<_> = METHODS + .into_iter() + .map(|method| tinybus::MemberName::new(method).unwrap()) + .collect(); + assert_eq!(members, published); } #[test] diff --git a/crates/tinydocs-module/tests/module_e2e.rs b/crates/tinydocs-module/tests/module_e2e.rs index c52e85e..c83558c 100644 --- a/crates/tinydocs-module/tests/module_e2e.rs +++ b/crates/tinydocs-module/tests/module_e2e.rs @@ -8,8 +8,11 @@ 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}; +use tinydocs_bus::{ + BUS_NAME, OBJECT_PATH, + docx::{DocumentSection, DocumentSpec}, + names::methods, +}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires TINYDOCS_TEST_MODULE to point at the built cdylib"] @@ -30,7 +33,7 @@ async fn built_cdylib_loads_and_generates_a_docx_over_the_bus() { .provides .iter() .flat_map(|interface| interface.methods.iter()) - .any(|method| method.as_str() == "GenerateDocx") + .any(|method| method.as_str() == methods::GENERATE_DOCX) ); let client = Connection::connect(bus.connect().await.unwrap()) @@ -56,7 +59,7 @@ async fn built_cdylib_loads_and_generates_a_docx_over_the_bus() { let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); let bytes: Vec = proxy .call( - "GenerateDocx", + methods::GENERATE_DOCX, (DocumentSpec { title: "TinyBus E2E".to_string(), author: Some("TinyDocs".to_string()), diff --git a/docs/specs/tinybus-module.md b/docs/specs/tinybus-module.md index a3ed6f0..52cfdbc 100644 --- a/docs/specs/tinybus-module.md +++ b/docs/specs/tinybus-module.md @@ -28,10 +28,13 @@ production. ## 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 +The `tinydocs-bus` workspace crate is the transport-free wire contract: bus +identity, member names, contract version, and `DocumentSpec` payload types. A +host can depend on it without compiling TinyBus or the OOXML writer. The public +`tinydocs` crate depends on and re-exports those exact types, while the private +`tinydocs-module` crate consumes the contract directly and builds as a +`cdylib`. This separation keeps 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 @@ -50,6 +53,9 @@ module itself retains no document state between calls. ## Invariants and constraints - The vendored TinyBus gitlink is the ABI source of truth. +- `tinydocs-bus` contains no transport or document-generation dependency. +- `tinydocs::docx::DocumentSpec` and `tinydocs_bus::docx::DocumentSpec` are + the same type, not structural copies. - 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 diff --git a/src/docx/mod.rs b/src/docx/mod.rs index 783f65c..8bab690 100644 --- a/src/docx/mod.rs +++ b/src/docx/mod.rs @@ -27,9 +27,7 @@ //! Whitespace-only paragraphs and bullets are trimmed away rather than //! emitting an empty run. -mod types; - -pub use types::{ +pub use tinydocs_bus::docx::{ DocumentSection, DocumentSpec, MAX_BULLETS_PER_SECTION, MAX_PARAGRAPH_CHARS, MAX_PARAGRAPHS_PER_SECTION, MAX_SECTIONS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, }; @@ -54,124 +52,6 @@ const HEADING_SIZE_HALF_PT: usize = 32; /// Run font size for the author byline, in half-points (12 pt). const AUTHOR_SIZE_HALF_PT: usize = 24; -impl DocumentSpec { - /// Check the spec against every documented size limit. - /// - /// Callers do not have to invoke this: [`generate`] validates before it - /// synthesises anything. It is public so a host can reject a malformed - /// spec at its own boundary — an LLM tool call, say — and hand back the - /// structured [`Error::InvalidInput`] before paying for a blocking hop. - /// - /// # Errors - /// - /// Returns [`Error::InvalidInput`] naming the first field that violates a - /// limit. Fields are checked in spec order (title, author, sections, then - /// each section's contents) so the reported field is stable for a given - /// spec. - pub fn validate(&self) -> Result<()> { - if self.title.trim().is_empty() { - return Err(Error::invalid_input("title", "must not be empty")); - } - if self.title.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - "title", - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - // Running total across every renderable field — title, author, and all - // section contents — checked as each field is processed. A spec can pass - // every per-field limit yet blow the aggregate budget, and checking - // incrementally rejects it as soon as the budget is crossed without a - // second pass over the whole spec. - let over_budget = || { - Error::invalid_input( - "sections", - format!("total document text must be ≤ {MAX_TOTAL_CHARS} chars"), - ) - }; - let mut total = self.title.chars().count(); - if let Some(author) = self.author.as_deref() { - if author.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - "author", - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - total = total.saturating_add(author.chars().count()); - } - if self.sections.is_empty() { - return Err(Error::invalid_input( - "sections", - "must contain at least one section", - )); - } - if self.sections.len() > MAX_SECTIONS { - return Err(Error::invalid_input( - "sections", - format!("must contain ≤ {MAX_SECTIONS} sections"), - )); - } - - for (i, section) in self.sections.iter().enumerate() { - if section.is_blank() { - return Err(Error::invalid_input( - format!("sections[{i}]"), - "must have at least one of heading / paragraphs / bullets", - )); - } - if let Some(heading) = section.heading.as_deref() { - if heading.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].heading"), - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - total = total.saturating_add(heading.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - if section.paragraphs.len() > MAX_PARAGRAPHS_PER_SECTION { - return Err(Error::invalid_input( - format!("sections[{i}].paragraphs"), - format!("must contain ≤ {MAX_PARAGRAPHS_PER_SECTION} paragraphs"), - )); - } - for (p, paragraph) in section.paragraphs.iter().enumerate() { - if paragraph.chars().count() > MAX_PARAGRAPH_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].paragraphs[{p}]"), - format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), - )); - } - total = total.saturating_add(paragraph.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - if section.bullets.len() > MAX_BULLETS_PER_SECTION { - return Err(Error::invalid_input( - format!("sections[{i}].bullets"), - format!("must contain ≤ {MAX_BULLETS_PER_SECTION} bullets"), - )); - } - for (b, bullet) in section.bullets.iter().enumerate() { - if bullet.chars().count() > MAX_PARAGRAPH_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].bullets[{b}]"), - format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), - )); - } - total = total.saturating_add(bullet.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - } - Ok(()) - } -} - /// Validate `spec` and synthesise it into `.docx` bytes. /// /// The returned buffer is a complete OOXML zip container: any Word-compatible diff --git a/src/docx/test.rs b/src/docx/test.rs index f724245..773c27f 100644 --- a/src/docx/test.rs +++ b/src/docx/test.rs @@ -322,26 +322,3 @@ fn generate_validates_before_synthesising() { s.title = String::new(); assert!(matches!(generate(&s), Err(Error::InvalidInput { .. }))); } - -#[test] -fn spec_round_trips_through_json() { - let s = spec(); - let json = serde_json::to_string(&s).expect("serialises"); - let back: DocumentSpec = serde_json::from_str(&json).expect("deserialises"); - assert_eq!(back, s); -} - -#[test] -fn spec_rejects_unknown_json_fields() { - // `deny_unknown_fields` makes a typo'd key a loud rejection rather than a - // silently ignored one — the whole point at an LLM tool boundary. - let json = r#"{"title":"T","sections":[],"titel":"typo"}"#; - assert!(serde_json::from_str::(json).is_err()); -} - -#[test] -fn spec_defaults_optional_fields() { - let s: DocumentSpec = serde_json::from_str(r#"{"title":"T"}"#).expect("deserialises"); - assert_eq!(s.author, None); - assert!(s.sections.is_empty()); -} diff --git a/src/docx/types.rs b/src/docx/types.rs deleted file mode 100644 index 56a41d1..0000000 --- a/src/docx/types.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! The `.docx` document spec: the typed description a caller hands to -//! [`generate`](super::generate), plus the size limits every spec is -//! validated against. -//! -//! The spec is the crate's wire contract. It derives `Serialize` / -//! `Deserialize` with `deny_unknown_fields` because the usual caller is an -//! LLM tool boundary: the same struct that drives synthesis is the one whose -//! JSON schema the model is shown, and a typo'd field name should be a loud -//! rejection rather than a silently ignored key. -//! -//! Limits are public consts rather than private constants so a host can quote -//! the exact number in its own tool description and stay in lockstep with what -//! validation actually enforces. - -use serde::{Deserialize, Serialize}; - -/// Maximum number of sections a single document may contain. -/// -/// Bounds generation time and output size; a caller with more material is -/// expected to split it across multiple documents. -pub const MAX_SECTIONS: usize = 128; - -/// Maximum length, in Unicode scalar values, of a short text field — the -/// document title, the author byline, or a section heading. -pub const MAX_TEXT_CHARS: usize = 2_000; - -/// Maximum length, in Unicode scalar values, of a single body paragraph or -/// bullet item. -/// -/// More generous than [`MAX_TEXT_CHARS`]: prose paragraphs legitimately run -/// far longer than a heading. -pub const MAX_PARAGRAPH_CHARS: usize = 20_000; - -/// Maximum number of body paragraphs in a single section. -pub const MAX_PARAGRAPHS_PER_SECTION: usize = 200; - -/// Maximum number of bullet-list items in a single section. -pub const MAX_BULLETS_PER_SECTION: usize = 200; - -/// Aggregate cap on all renderable text across the whole document — the -/// title, the author byline, and every section's heading, paragraphs, and -/// bullets — in Unicode scalar values. -/// -/// The per-field and per-section limits above bound each individual piece, but -/// not their product — `MAX_SECTIONS × MAX_PARAGRAPHS_PER_SECTION × -/// MAX_PARAGRAPH_CHARS` alone is over 500M characters, so a spec satisfying -/// every other limit could still build a multi-hundred-megabyte document in -/// memory. This total keeps the worst case bounded to a few megabytes of text -/// while staying generous for any real document. -pub const MAX_TOTAL_CHARS: usize = 2_000_000; - -/// One section of the document, rendered in spec order. -/// -/// A section is an optional heading followed by any number of body paragraphs -/// and/or a bullet list. At least one of the three must carry renderable text — -/// a wholly blank section is rejected by [`DocumentSpec::validate`] rather than -/// silently rendering nothing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DocumentSection { - /// Section heading, rendered as a bold heading paragraph. Optional: a - /// section may be pure body text under the document title. - #[serde(default)] - pub heading: Option, - /// Body paragraphs, each rendered as its own paragraph, in order. - /// Blank and whitespace-only entries are dropped during synthesis. - #[serde(default)] - pub paragraphs: Vec, - /// Bullet-list items, rendered as a single-level bulleted list after the - /// section's body paragraphs. Blank and whitespace-only entries are - /// dropped during synthesis. - #[serde(default)] - pub bullets: Vec, -} - -impl DocumentSection { - /// Returns `true` when the section carries no renderable content at all — - /// the heading is absent or blank, and every paragraph and bullet is blank. - /// - /// Synthesis trims and drops blank entries, so a section holding only - /// `[" "]` would render as nothing despite carrying entries. Validation - /// uses this to reject that case up front. - #[must_use] - pub fn is_blank(&self) -> bool { - let has_heading = self - .heading - .as_deref() - .is_some_and(|h| !h.trim().is_empty()); - let has_paragraph = self.paragraphs.iter().any(|p| !p.trim().is_empty()); - let has_bullet = self.bullets.iter().any(|b| !b.trim().is_empty()); - !(has_heading || has_paragraph || has_bullet) - } -} - -/// A complete `.docx` document spec. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DocumentSpec { - /// Document title, rendered as the leading title paragraph. Required and - /// non-blank. - pub title: String, - /// Optional author byline, rendered as an italic line beneath the title. - #[serde(default)] - pub author: Option, - /// Sections, in display order. Must contain at least one entry. - #[serde(default)] - pub sections: Vec, -} - -impl DocumentSpec { - /// Total renderable text across the whole spec, in Unicode scalar values. - /// - /// Sums with saturating arithmetic so an adversarial spec cannot overflow - /// the counter into a small value that passes the aggregate check. - #[must_use] - pub fn total_chars(&self) -> usize { - let mut total = self.title.chars().count(); - if let Some(author) = self.author.as_deref() { - total = total.saturating_add(author.chars().count()); - } - for section in &self.sections { - if let Some(heading) = section.heading.as_deref() { - total = total.saturating_add(heading.chars().count()); - } - for paragraph in §ion.paragraphs { - total = total.saturating_add(paragraph.chars().count()); - } - for bullet in §ion.bullets { - total = total.saturating_add(bullet.chars().count()); - } - } - total - } -} diff --git a/src/error/mod.rs b/src/error/mod.rs deleted file mode 100644 index fd8d8fd..0000000 --- a/src/error/mod.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! The variants are deliberately *host-agnostic*. A host that surfaces these -//! to an LLM (the reason [`Error::InvalidInput`] carries a structured -//! `field` / `reason` pair rather than a formatted sentence) maps them onto -//! its own tool-error shape; a host writing to disk maps them onto its own. -//! Nothing here knows about artifacts, timeouts, or async runtimes — those are -//! the host's concerns, because only the host knows its own deadline policy. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A document spec failed validation before any synthesis was attempted. - /// - /// `field` names the offending path in the spec using the same dotted / - /// indexed notation the JSON input uses (`sections[2].bullets[0]`), so an - /// LLM that produced the spec can self-correct without re-reading the - /// whole schema. `reason` states the violated constraint. - #[error("invalid input for field '{field}': {reason}")] - InvalidInput { - /// Path of the offending field within the spec. - field: String, - /// The constraint that was violated. - reason: String, - }, - - /// The underlying document library failed to synthesise the output. - /// - /// `detail` is the library's own error rendered as text and truncated to a - /// bounded length, so the variant never carries an unbounded payload back - /// to a caller that forwards it to a model. - #[error("document generation failed: {detail}")] - GenerationFailed { - /// Truncated underlying library error. - detail: String, - }, -} - -impl Error { - /// Maximum length, in Unicode scalar values, of a [`Error::GenerationFailed`] - /// detail string. - pub const MAX_DETAIL_CHARS: usize = 500; - - /// Suffix appended when a detail string is truncated. - const TRUNCATION_SUFFIX: &'static str = " […truncated]"; - - /// Build a [`Error::GenerationFailed`] with `raw` truncated (UTF-8-safe) to - /// [`Error::MAX_DETAIL_CHARS`]. - /// - /// Truncation counts characters, not bytes, so a multi-byte error message - /// can never be cut mid-codepoint. - #[must_use] - pub fn generation_failed(raw: &str) -> Self { - Self::GenerationFailed { - detail: Self::truncate_detail(raw), - } - } - - /// Truncate `raw` to [`Error::MAX_DETAIL_CHARS`] characters, appending the - /// standard truncation suffix when anything was dropped. - #[must_use] - pub fn truncate_detail(raw: &str) -> String { - if raw.chars().count() <= Self::MAX_DETAIL_CHARS { - return raw.to_string(); - } - let keep = Self::MAX_DETAIL_CHARS.saturating_sub(Self::TRUNCATION_SUFFIX.chars().count()); - let mut out: String = raw.chars().take(keep).collect(); - out.push_str(Self::TRUNCATION_SUFFIX); - out - } - - /// Build an [`Error::InvalidInput`] for `field` violating `reason`. - #[must_use] - pub fn invalid_input(field: impl Into, reason: impl Into) -> Self { - Self::InvalidInput { - field: field.into(), - reason: reason.into(), - } - } -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/src/error/test.rs b/src/error/test.rs deleted file mode 100644 index 7bc5815..0000000 --- a/src/error/test.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::Error; - -#[test] -fn short_details_are_left_intact() { - let err = Error::generation_failed("boom"); - assert_eq!( - err, - Error::GenerationFailed { - detail: "boom".to_string() - } - ); -} - -#[test] -fn long_details_are_truncated_with_a_suffix() { - let raw = "x".repeat(Error::MAX_DETAIL_CHARS * 2); - let Error::GenerationFailed { detail } = Error::generation_failed(&raw) else { - panic!("expected GenerationFailed"); - }; - assert_eq!(detail.chars().count(), Error::MAX_DETAIL_CHARS); - assert!(detail.ends_with("[…truncated]")); -} - -#[test] -fn truncation_never_splits_a_multi_byte_character() { - // Every character is 4 bytes, so a byte-based truncation would panic or - // produce invalid UTF-8. Counting characters keeps the boundary valid. - let raw = "🦀".repeat(Error::MAX_DETAIL_CHARS * 2); - let detail = Error::truncate_detail(&raw); - assert_eq!(detail.chars().count(), Error::MAX_DETAIL_CHARS); - assert!(detail.starts_with('🦀')); -} - -#[test] -fn detail_at_exactly_the_cap_is_not_truncated() { - let raw = "y".repeat(Error::MAX_DETAIL_CHARS); - assert_eq!(Error::truncate_detail(&raw), raw); -} - -#[test] -fn invalid_input_carries_the_field_path_verbatim() { - let err = Error::invalid_input("sections[2].bullets[0]", "must be ≤ 10 chars"); - assert_eq!( - err, - Error::InvalidInput { - field: "sections[2].bullets[0]".to_string(), - reason: "must be ≤ 10 chars".to_string(), - } - ); -} diff --git a/src/lib.rs b/src/lib.rs index f1221b8..9e74c4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,9 @@ //! //! # Layout //! -//! - [`error`](self::Error) — the crate-wide [`Error`] and [`Result`]. +//! - [`Error`] and [`Result`] — the shared document and bus error contract. +//! - [`tinydocs_bus`] — the transport-free `TinyBus` vocabulary re-exported by +//! this crate's document API. #![cfg_attr( feature = "docx", doc = "- [`docx`] — `.docx` (OOXML `WordprocessingML`) synthesis." @@ -60,9 +62,7 @@ //! - `docx` (default) — `.docx` synthesis via `docx-rs`. Turning it off drops //! the whole OOXML writer stack. -mod error; - #[cfg(feature = "docx")] pub mod docx; -pub use error::{Error, Result}; +pub use tinydocs_bus::{Error, Result}; diff --git a/tests/public_api.rs b/tests/public_api.rs index c271662..8cac379 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -65,3 +65,10 @@ fn limits_are_visible_to_consumers() { invalid.title = "t".repeat(docx::MAX_TEXT_CHARS + 1); assert!(invalid.validate().is_err()); } + +#[test] +fn document_spec_is_the_bus_contract_type() { + fn accepts_bus_spec(_: tinydocs_bus::docx::DocumentSpec) {} + + accepts_bus_spec(spec()); +} From 767da624b235d0a2676a6799d8503bcf2d385b3a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:14:39 +0300 Subject: [PATCH 2/2] Address TinyBus contract review Co-authored-by: Medulla --- .github/workflows/release.yml | 6 ++---- Cargo.toml | 6 +++++- crates/tinydocs-bus/Cargo.toml | 2 +- crates/tinydocs-bus/README.md | 6 +++--- crates/tinydocs-module/Cargo.toml | 2 +- crates/tinydocs-module/src/lib.rs | 6 +++++- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f47550e..8169398 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,9 +129,7 @@ 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 - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' crates/tinydocs-bus/Cargo.toml - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' crates/tinydocs-module/Cargo.toml + perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml cargo update -p tinydocs-bus --precise "$NEXT_VERSION" cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" @@ -142,7 +140,7 @@ 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 crates/tinydocs-bus/Cargo.toml crates/tinydocs-module/Cargo.toml + git add Cargo.toml Cargo.lock git commit -m "Release ${RELEASE_TAG}" git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" diff --git a/Cargo.toml b/Cargo.toml index eca53d1..acae9d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinydocs" -version = "0.1.13" +version.workspace = true edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -27,6 +27,10 @@ default-members = [".", "crates/tinydocs-bus", "crates/tinydocs-module"] exclude = ["vendor/tinybus"] resolver = "3" +[workspace.package] +# Releases update this sole workspace version before packaging every crate. +version = "0.1.13" + [dependencies] # The dependency-light TinyBus contract owns payload types and errors. Re-export # it so library and bus callers use the exact same values. diff --git a/crates/tinydocs-bus/Cargo.toml b/crates/tinydocs-bus/Cargo.toml index a0b5512..ffc6dc9 100644 --- a/crates/tinydocs-bus/Cargo.toml +++ b/crates/tinydocs-bus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinydocs-bus" -version = "0.1.13" +version.workspace = true edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" diff --git a/crates/tinydocs-bus/README.md b/crates/tinydocs-bus/README.md index 7eb890e..81651cb 100644 --- a/crates/tinydocs-bus/README.md +++ b/crates/tinydocs-bus/README.md @@ -11,6 +11,6 @@ or the loadable module. `tinydocs` depends on and re-exports the same types, so `tinydocs::docx::DocumentSpec` and `tinydocs_bus::spec::DocumentSpec` are one type, not compatible-looking duplicates. -The module serves the [`METHODS`] at [`BUS_NAME`] and [`OBJECT_PATH`]. Keep -changes here backward compatible or advance -[`CONTRACT_VERSION`] according to the documented compatibility rule. +The module serves the `METHODS` at `BUS_NAME` and `OBJECT_PATH`. Keep changes +here backward compatible or advance `CONTRACT_VERSION` according to the +documented compatibility rule. diff --git a/crates/tinydocs-module/Cargo.toml b/crates/tinydocs-module/Cargo.toml index b9d7c96..5b2879d 100644 --- a/crates/tinydocs-module/Cargo.toml +++ b/crates/tinydocs-module/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinydocs-module" -version = "0.1.13" +version.workspace = true edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" diff --git a/crates/tinydocs-module/src/lib.rs b/crates/tinydocs-module/src/lib.rs index da26c13..fb61bbe 100644 --- a/crates/tinydocs-module/src/lib.rs +++ b/crates/tinydocs-module/src/lib.rs @@ -8,4 +8,8 @@ pub mod outputs; mod service; pub use outputs::{OutputError, OutputRef, OutputStore, hex_digest}; -pub use tinydocs_bus::*; +pub use tinydocs_bus::{ + BUS_NAME, CONTRACT_VERSION, DocumentSection, DocumentSpec, Error, ImageFormat, METHODS, + OBJECT_PATH, PresentationSpec, Result, SlideImage, SlideSpec, WirePresentationSpec, + WireSlideImage, WireSlideSpec, is_compatible, +};