diff --git a/Cargo.lock b/Cargo.lock index 4c271f3..803afec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,285 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "docx-rs" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fdf00e8af6d0b3e92d4bbf9b76f773d8b84ea80f310324ad16cbdc2e653e02c" +dependencies = [ + "base64", + "crc32fast", + "image", + "quick-xml", + "serde", + "serde_json", + "smallvec", + "thiserror", + "zip 8.6.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -11,6 +290,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -21,10 +322,69 @@ dependencies = [ ] [[package]] -name = "rust-template" -version = "0.1.0" +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "thiserror", + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -55,11 +415,153 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", ] +[[package]] +name = "tinydocs" +version = "0.1.0" +dependencies = [ + "docx-rs", + "serde", + "serde_json", + "thiserror", + "zip 2.4.2", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.3", +] diff --git a/Cargo.toml b/Cargo.toml index c897905..01f77b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "rust-template" +name = "tinydocs" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "GPL-3.0-only" -description = "A production-ready Rust library template." -repository = "https://github.com/tinyhumansai/rust-template" -documentation = "https://docs.rs/rust-template" +description = "Agent-friendly document synthesis and text extraction (DOCX, PPTX, PDF) in Rust." +repository = "https://github.com/tinyhumansai/tinydocs" +documentation = "https://docs.rs/tinydocs" readme = "README.md" -keywords = ["template"] -categories = ["development-tools"] +keywords = ["docx", "ooxml", "pdf", "document", "agent"] +categories = ["text-processing"] # Keep the published package to what a consumer actually needs. exclude = [ ".github/", @@ -25,11 +25,33 @@ exclude = [ # 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"] } +# 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 } [dev-dependencies] +# `.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 +# reporting the (correct) fact that the crate itself compiles fine. +[[example]] +name = "basic" +required-features = ["docx"] [features] -default = [] +default = ["docx"] +# `.docx` generation via `docx-rs`. +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. diff --git a/README.md b/README.md index 5fa22ad..b0292c1 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,78 @@ -# Rust Template +# TinyDocs + +Agent-friendly document synthesis and text extraction in Rust. + +`tinydocs` turns a typed, validated document spec into real office-format +bytes. It is built for hosts that let a language model produce documents: the +spec types double as the JSON tool schema, validation rejects a malformed spec +with a structured error naming the exact offending field so the model can +self-correct, and synthesis hands back a plain byte buffer. + +```rust +use tinydocs::docx::{self, DocumentSection, DocumentSpec}; + +let spec = DocumentSpec { + title: "Weekly Report".to_string(), + author: Some("Ferris".to_string()), + sections: vec![DocumentSection { + heading: Some("Highlights".to_string()), + paragraphs: vec!["Throughput doubled.".to_string()], + bullets: vec!["Shipped the parser".to_string()], + }], +}; + +let bytes = docx::generate(&spec)?; +std::fs::write("report.docx", bytes)?; +# Ok::<(), Box>(()) +``` + +## What it does not do + +No filesystem access, no subprocesses, no async runtime, no deadline handling. +`docx::generate` is synchronous and CPU-bound. + +That is a deliberate seam, not an omission. A host running on an async executor +owns the blocking-pool hop and the timeout, because only the host knows its own +executor and deadline policy — a crate that guessed at either would be wrong +for every host that guessed differently. The typical async caller looks like: + +```rust,ignore +let spec = spec.clone(); +let bytes = tokio::time::timeout( + deadline, + tokio::task::spawn_blocking(move || tinydocs::docx::generate(&spec)), +) +.await???; +``` -A production-ready Rust 2024 library template used by TinyHumans AI. It ships -the module layout, lint configuration, error handling, testing, documentation, -CI, and release workflow that every new crate in this organization starts from — -plus one small feature module that demonstrates the conventions end to end. +## Validation -## Use This Template +Every limit is a public constant, so a host can quote the exact number in its +own tool description and stay in lockstep with what validation enforces. -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): +| Limit | Value | Bounds | +| --- | --- | --- | +| `MAX_SECTIONS` | 128 | sections per document | +| `MAX_TEXT_CHARS` | 2,000 | title, author, section heading | +| `MAX_PARAGRAPH_CHARS` | 20,000 | one paragraph or bullet | +| `MAX_PARAGRAPHS_PER_SECTION` | 200 | paragraphs per section | +| `MAX_BULLETS_PER_SECTION` | 200 | bullets per section | +| `MAX_TOTAL_CHARS` | 2,000,000 | all text in the document | -- 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; -- 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. +The aggregate cap is the load-bearing one. The per-field limits 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. -Search for `rust-template` and `rust_template` to find every remaining -template-specific value. +`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. -## What You Get +## Feature flags -| 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), rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and publishes to crates.io | -| 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 | -| Vendor | TinyBus pinned as the `vendor/tinybus` submodule, initialized by CI and release workflows | +| Feature | Default | Gates | +| --- | --- | --- | +| `docx` | on | `.docx` synthesis via `docx-rs` | ## Layout @@ -41,65 +82,38 @@ src/ ├── 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 +└── docx/ + ├── mod.rs # `generate` + spec validation + ├── types.rs # `DocumentSpec`, `DocumentSection`, limits + └── test.rs tests/ └── public_api.rs # integration tests against the public API only examples/ └── basic.rs # compiled and linted in CI -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── 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. - ## Development -Clone with submodules, or initialize them before building: - ```sh git submodule update --init --recursive -``` -```sh 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 ``` -Those four checks are exactly what CI runs. Optional extras: +Run the gated build too — it is the only thing that catches a feature that +compiles only when it is turned on: ```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml +cargo clippy --all-targets --no-default-features -- -D warnings ``` -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. It revalidates the crate, bumps the version, commits, tags -`vX.Y.Z`, and publishes to crates.io. Do not hand-edit the version in -`Cargo.toml`. - ## Documentation - [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents - [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records - [`SECURITY.md`](SECURITY.md) — how to report a vulnerability ## License diff --git a/examples/basic.rs b/examples/basic.rs index 6fa02b8..cb321b4 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,4 +1,4 @@ -//! Minimal end-to-end usage of the crate. +//! Generate a small `.docx` and report its size. //! //! Examples are compiled and linted in CI, so they cannot drift from the API. //! Run it with: @@ -7,16 +7,42 @@ //! cargo run --example basic //! ``` -use rust_template::{Result, greet}; +use tinydocs::docx::{self, DocumentSection, DocumentSpec}; -fn main() -> Result<()> { - println!("{}", greet("Rust")?); +fn main() { + let spec = DocumentSpec { + title: "Quarterly Review".to_string(), + author: Some("Ferris".to_string()), + sections: vec![ + DocumentSection { + heading: Some("Summary".to_string()), + paragraphs: vec!["Throughput doubled while error rates fell.".to_string()], + bullets: vec![], + }, + DocumentSection { + heading: Some("Next Quarter".to_string()), + paragraphs: vec![], + bullets: vec![ + "Ship the streaming parser".to_string(), + "Halve p99 latency".to_string(), + ], + }, + ], + }; - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), + match docx::generate(&spec) { + Ok(bytes) => println!("generated {} bytes of .docx", bytes.len()), + Err(error) => println!("generation failed: {error}"), } - Ok(()) + // Failure modes are part of the public contract, so show one too. An empty + // title is rejected before any synthesis happens. + let invalid = DocumentSpec { + title: " ".to_string(), + ..spec + }; + match docx::generate(&invalid) { + Ok(bytes) => println!("unexpectedly generated {} bytes", bytes.len()), + Err(error) => println!("expected failure: {error}"), + } } diff --git a/src/docx/mod.rs b/src/docx/mod.rs new file mode 100644 index 0000000..783f65c --- /dev/null +++ b/src/docx/mod.rs @@ -0,0 +1,298 @@ +//! `.docx` (OOXML `WordprocessingML`) synthesis, backed by +//! [`docx-rs`](https://crates.io/crates/docx-rs). +//! +//! [`generate`] turns a validated [`DocumentSpec`] into the bytes of a +//! `.docx` file. It is **synchronous, pure, and CPU-bound**: it touches no +//! filesystem, spawns no subprocess, and knows nothing about deadlines. A +//! host that needs a timeout or a blocking-pool hop owns that policy and wraps +//! this call — which is the whole reason it stays synchronous here, since only +//! the host knows whether it is running on an async executor at all. +//! +//! # Spec → OOXML mapping +//! +//! The document is emitted as a linear paragraph stream: +//! +//! ```text +//! title → bold, large "Title"-styled paragraph +//! author (opt) → italic paragraph beneath the title +//! per section: +//! heading (opt) → bold "Heading1"-styled paragraph +//! paragraphs[] → one normal paragraph each +//! bullets[] → single-level `•` list (shared numbering id) +//! ``` +//! +//! Headings carry both a style id (`Title` / `Heading1`, which Word maps to +//! its built-in outline styles) and an explicit bold + size run, so the visual +//! hierarchy survives even in a reader that ignores the style table. +//! Whitespace-only paragraphs and bullets are trimmed away rather than +//! emitting an empty run. + +mod types; + +pub use types::{ + DocumentSection, DocumentSpec, MAX_BULLETS_PER_SECTION, MAX_PARAGRAPH_CHARS, + MAX_PARAGRAPHS_PER_SECTION, MAX_SECTIONS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, +}; + +use docx_rs::{ + AbstractNumbering, Docx, IndentLevel, Level, LevelJc, LevelText, NumberFormat, Numbering, + NumberingId, Paragraph, Run, Start, +}; + +use crate::{Error, Result}; + +/// Shared numbering id for the single-level bullet list. +/// +/// One abstract numbering plus one concrete numbering is registered on the +/// document, and every bullet paragraph references it at indent level 0. +const BULLET_NUMBERING_ID: usize = 1; + +/// Run font size for the document title, in OOXML half-points (28 pt). +const TITLE_SIZE_HALF_PT: usize = 56; +/// Run font size for a section heading, in half-points (16 pt). +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 +/// reader can open it, and a host can write it straight to disk or stream it. +/// +/// Synchronous and CPU-bound. Synthesis of a document at the section cap +/// completes well under 100 ms, but a host on an async executor should still +/// run this on a blocking pool rather than inline. +/// +/// # Errors +/// +/// - [`Error::InvalidInput`] if `spec` violates any documented limit — no +/// synthesis is attempted. +/// - [`Error::GenerationFailed`] if `docx-rs` fails to pack the document. +/// +/// # Examples +/// +/// ``` +/// use tinydocs::docx::{generate, DocumentSection, DocumentSpec}; +/// +/// let spec = DocumentSpec { +/// title: "Project Charter".to_string(), +/// author: Some("Alice".to_string()), +/// sections: vec![DocumentSection { +/// heading: Some("Goals".to_string()), +/// paragraphs: vec!["Ship the first release.".to_string()], +/// bullets: vec!["Fast".to_string(), "Correct".to_string()], +/// }], +/// }; +/// +/// let bytes = generate(&spec)?; +/// assert_eq!(&bytes[0..2], b"PK", "a .docx is a zip container"); +/// # Ok::<(), tinydocs::Error>(()) +/// ``` +pub fn generate(spec: &DocumentSpec) -> Result> { + spec.validate()?; + + // `XMLDocx::pack` takes a `Write + Seek` writer by value; a + // `&mut Cursor>` satisfies both, so we pack into an in-memory + // buffer and recover the bytes via `into_inner`. + let mut cursor = std::io::Cursor::new(Vec::::new()); + build(spec) + .build() + .pack(&mut cursor) + .map_err(|err| Error::generation_failed(&err.to_string()))?; + Ok(cursor.into_inner()) +} + +/// Pure transformation from the spec to a `docx-rs` [`Docx`]. +/// +/// Split out from [`generate`] for unit-testability: the paragraph ordering +/// and blank-filtering rules are load-bearing for the rendered document shape. +fn build(spec: &DocumentSpec) -> Docx { + // Register the shared single-level bullet list once. `NumberFormat` + // "bullet" plus a `•` level text renders an unordered list; every bullet + // paragraph binds to this numbering id at indent level 0. + let mut docx = Docx::new() + .add_abstract_numbering( + AbstractNumbering::new(BULLET_NUMBERING_ID).add_level(Level::new( + 0, + Start::new(1), + NumberFormat::new("bullet"), + LevelText::new("•"), + LevelJc::new("left"), + )), + ) + .add_numbering(Numbering::new(BULLET_NUMBERING_ID, BULLET_NUMBERING_ID)); + + // Title — bold and large, styled as the built-in "Title" outline style. + docx = docx.add_paragraph( + Paragraph::new().style("Title").add_run( + Run::new() + .add_text(spec.title.trim()) + .bold() + .size(TITLE_SIZE_HALF_PT), + ), + ); + + // Author byline — italic, when present and non-blank. + if let Some(author) = spec.author.as_deref().filter(|a| !a.trim().is_empty()) { + docx = docx.add_paragraph( + Paragraph::new().add_run( + Run::new() + .add_text(author.trim()) + .italic() + .size(AUTHOR_SIZE_HALF_PT), + ), + ); + } + + for section in &spec.sections { + if let Some(heading) = section.heading.as_deref().filter(|h| !h.trim().is_empty()) { + docx = docx.add_paragraph( + Paragraph::new().style("Heading1").add_run( + Run::new() + .add_text(heading.trim()) + .bold() + .size(HEADING_SIZE_HALF_PT), + ), + ); + } + for paragraph in §ion.paragraphs { + let text = paragraph.trim(); + if !text.is_empty() { + docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text(text))); + } + } + for bullet in §ion.bullets { + let text = bullet.trim(); + if !text.is_empty() { + docx = docx.add_paragraph( + Paragraph::new() + .add_run(Run::new().add_text(text)) + .numbering(NumberingId::new(BULLET_NUMBERING_ID), IndentLevel::new(0)), + ); + } + } + } + + docx +} + +#[cfg(test)] +mod test; diff --git a/src/docx/test.rs b/src/docx/test.rs new file mode 100644 index 0000000..f724245 --- /dev/null +++ b/src/docx/test.rs @@ -0,0 +1,347 @@ +//! Unit tests for `.docx` validation and synthesis. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ + DocumentSection, DocumentSpec, MAX_BULLETS_PER_SECTION, MAX_PARAGRAPH_CHARS, + MAX_PARAGRAPHS_PER_SECTION, MAX_SECTIONS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, generate, +}; +use crate::Error; + +/// One valid section carrying a heading, a paragraph, and a bullet. +fn section() -> DocumentSection { + DocumentSection { + heading: Some("Overview".to_string()), + paragraphs: vec!["A body paragraph.".to_string()], + bullets: vec!["A bullet".to_string()], + } +} + +/// A minimal valid spec; each test mutates one field to drive a single branch. +fn spec() -> DocumentSpec { + DocumentSpec { + title: "Charter".to_string(), + author: Some("Alice".to_string()), + sections: vec![section()], + } +} + +/// Assert `spec` is rejected with an `InvalidInput` naming `field`. +fn assert_rejects(spec: &DocumentSpec, field: &str) { + match spec.validate() { + Err(Error::InvalidInput { field: f, .. }) => { + assert_eq!(f, field, "unexpected rejected field"); + } + other => panic!("expected InvalidInput({field}), got {other:?}"), + } +} + +/// Entry names inside a produced `.docx` byte buffer. +fn entry_names(bytes: &[u8]) -> Vec { + let mut zip = + zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("output is a valid zip"); + (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .collect() +} + +/// One entry's UTF-8 body out of a produced `.docx`. +fn entry_body(bytes: &[u8], name: &str) -> String { + let mut zip = + zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).expect("output is a valid zip"); + let mut entry = zip.by_name(name).expect("entry present"); + let mut body = String::new(); + std::io::Read::read_to_string(&mut entry, &mut body).unwrap(); + body +} + +#[test] +fn accepts_a_well_formed_spec() { + assert!(spec().validate().is_ok()); +} + +#[test] +fn rejects_a_blank_title() { + let mut s = spec(); + s.title = " ".to_string(); + assert_rejects(&s, "title"); +} + +#[test] +fn rejects_an_over_long_title() { + let mut s = spec(); + s.title = "t".repeat(MAX_TEXT_CHARS + 1); + assert_rejects(&s, "title"); +} + +#[test] +fn rejects_an_over_long_author() { + let mut s = spec(); + s.author = Some("a".repeat(MAX_TEXT_CHARS + 1)); + assert_rejects(&s, "author"); +} + +#[test] +fn rejects_a_spec_with_no_sections() { + let mut s = spec(); + s.sections.clear(); + assert_rejects(&s, "sections"); +} + +#[test] +fn rejects_too_many_sections() { + let mut s = spec(); + s.sections = vec![section(); MAX_SECTIONS + 1]; + assert_rejects(&s, "sections"); +} + +#[test] +fn rejects_a_wholly_blank_section() { + // Every entry is present but whitespace-only, so synthesis would drop all + // of them and render nothing. Validation catches it instead. + let mut s = spec(); + s.sections = vec![DocumentSection { + heading: Some(" ".to_string()), + paragraphs: vec!["\t".to_string()], + bullets: vec![String::new()], + }]; + assert_rejects(&s, "sections[0]"); +} + +#[test] +fn rejects_an_over_long_heading_naming_its_index() { + let mut s = spec(); + s.sections.push(DocumentSection { + heading: Some("h".repeat(MAX_TEXT_CHARS + 1)), + ..section() + }); + assert_rejects(&s, "sections[1].heading"); +} + +#[test] +fn rejects_too_many_paragraphs() { + let mut s = spec(); + s.sections[0].paragraphs = vec!["p".to_string(); MAX_PARAGRAPHS_PER_SECTION + 1]; + assert_rejects(&s, "sections[0].paragraphs"); +} + +#[test] +fn rejects_an_over_long_paragraph_naming_its_index() { + let mut s = spec(); + s.sections[0].paragraphs = vec!["ok".to_string(), "p".repeat(MAX_PARAGRAPH_CHARS + 1)]; + assert_rejects(&s, "sections[0].paragraphs[1]"); +} + +#[test] +fn rejects_too_many_bullets() { + let mut s = spec(); + s.sections[0].bullets = vec!["b".to_string(); MAX_BULLETS_PER_SECTION + 1]; + assert_rejects(&s, "sections[0].bullets"); +} + +#[test] +fn rejects_an_over_long_bullet_naming_its_index() { + let mut s = spec(); + s.sections[0].bullets = vec!["ok".to_string(), "b".repeat(MAX_PARAGRAPH_CHARS + 1)]; + assert_rejects(&s, "sections[0].bullets[1]"); +} + +#[test] +fn rejects_a_spec_over_the_aggregate_character_budget() { + // Each individual field is within its own limit; only the sum is not. One + // section with just enough max-length paragraphs to cross MAX_TOTAL_CHARS + // reproduces that without allocating hundreds of megabytes: repeating a + // whole section MAX_SECTIONS times (the original fixture) built ~512 MB + // of paragraph text before validation ever ran. + let paragraph_count = MAX_TOTAL_CHARS / MAX_PARAGRAPH_CHARS + 1; + assert!(paragraph_count <= MAX_PARAGRAPHS_PER_SECTION); + let paragraph = "x".repeat(MAX_PARAGRAPH_CHARS); + let big = DocumentSection { + heading: Some("Heading".to_string()), + paragraphs: vec![paragraph; paragraph_count], + bullets: vec![], + }; + let s = DocumentSpec { + title: "Huge".to_string(), + author: None, + sections: vec![big], + }; + // Sanity: this spec passes every per-field check. + assert!(s.sections.len() <= MAX_SECTIONS); + assert_rejects(&s, "sections"); +} + +#[test] +fn is_blank_reflects_content_presence() { + assert!(!section().is_blank()); + assert!( + DocumentSection { + heading: None, + paragraphs: vec![], + bullets: vec![], + } + .is_blank() + ); + // A heading alone is enough content. + assert!( + !DocumentSection { + heading: Some("Only a heading".to_string()), + paragraphs: vec![], + bullets: vec![], + } + .is_blank() + ); +} + +#[test] +fn total_chars_sums_every_text_field() { + let s = DocumentSpec { + title: "abcd".to_string(), // 4 + author: Some("xy".to_string()), // 2 + sections: vec![DocumentSection { + heading: Some("hij".to_string()), // 3 + paragraphs: vec!["pq".to_string()], // 2 + bullets: vec!["b".to_string()], // 1 + }], + }; + assert_eq!(s.total_chars(), 12); +} + +#[test] +fn generate_produces_a_readable_ooxml_container() { + let bytes = generate(&spec()).expect("generation should succeed"); + + // A `.docx` is a zip; `PK` is the local-file-header signature. This is the + // check that any OOXML reader can open the file at all. + assert_eq!(&bytes[0..2], b"PK", "must start with the zip magic PK"); + assert!( + bytes.len() > 200, + "unexpectedly small ({} bytes)", + bytes.len() + ); + + let names = entry_names(&bytes); + for required in ["[Content_Types].xml", "_rels/.rels", "word/document.xml"] { + assert!( + names.iter().any(|n| n == required), + "missing OOXML entry {required} (got {names:?})" + ); + } + // Numbering was used, so the numbering part must materialise. + assert!( + names.iter().any(|n| n == "word/numbering.xml"), + "a bullet list should emit word/numbering.xml (got {names:?})" + ); +} + +#[test] +fn generate_carries_every_text_field_into_the_body() { + let s = DocumentSpec { + title: "Project Charter".to_string(), + author: Some("Alice".to_string()), + sections: vec![ + DocumentSection { + heading: Some("Overview".to_string()), + paragraphs: vec!["This document describes the plan.".to_string()], + bullets: vec![], + }, + DocumentSection { + heading: Some("Goals".to_string()), + paragraphs: vec![], + bullets: vec!["Ship v1".to_string(), "Delight users".to_string()], + }, + ], + }; + let body = entry_body( + &generate(&s).expect("generation should succeed"), + "word/document.xml", + ); + for needle in [ + "Project Charter", + "Alice", + "Overview", + "This document describes the plan.", + "Goals", + "Ship v1", + "Delight users", + ] { + assert!( + body.contains(needle), + "document.xml missing text {needle:?}" + ); + } +} + +#[test] +fn generate_drops_blank_paragraphs_and_bullets() { + // Whitespace-only entries must not fail generation and must not emit empty + // runs — they are trimmed away. + let s = DocumentSpec { + title: "Trimmed".to_string(), + author: Some(" ".to_string()), + sections: vec![DocumentSection { + heading: Some("Kept".to_string()), + paragraphs: vec!["real".to_string(), " ".to_string(), String::new()], + bullets: vec!["item".to_string(), "\t\n".to_string()], + }], + }; + let body = entry_body( + &generate(&s).expect("generation should succeed"), + "word/document.xml", + ); + assert!(body.contains("real")); + assert!(body.contains("item")); + assert!(body.contains("Kept")); + + // Presence alone would still pass if the blank-filtering guards in `build` + // regressed and started emitting empty/whitespace runs alongside the kept + // ones. Pin the exact paragraph count too: title, heading, one kept + // paragraph, one kept bullet — no run for the blank author, the two blank + // paragraphs, or the whitespace-only bullet. + assert_eq!( + body.matches("(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 new file mode 100644 index 0000000..56a41d1 --- /dev/null +++ b/src/docx/types.rs @@ -0,0 +1,134 @@ +//! 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 index b8ddbe0..fd8d8fd 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -5,17 +5,84 @@ //! encoding new context into an existing message: callers match on variants, //! and message text is not a stable API. //! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. +//! 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 required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, + /// 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. diff --git a/src/error/test.rs b/src/error/test.rs index 4c5d609..7bc5815 100644 --- a/src/error/test.rs +++ b/src/error/test.rs @@ -2,16 +2,53 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::*; +use super::Error; #[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); +fn short_details_are_left_intact() { + let err = Error::generation_failed("boom"); + assert_eq!( + err, + Error::GenerationFailed { + detail: "boom".to_string() + } + ); } #[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} +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('🦀')); +} - assert_error(&Error::EmptyName); +#[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/greeting/mod.rs b/src/greeting/mod.rs deleted file mode 100644 index 5b4ad65..0000000 --- a/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use rust_template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), rust_template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/src/greeting/test.rs b/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/src/lib.rs b/src/lib.rs index 95bb03c..f1221b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,34 +1,68 @@ -//! A production-ready starting point for a Rust library. +//! Agent-friendly document synthesis in Rust. //! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`, -//! plus one small feature module ([`greet`]) that demonstrates them end to end. +//! `tinydocs` turns a typed, validated document spec into real office-format +//! bytes. It is built for hosts that let a language model produce documents: +//! the spec types are the JSON tool schema, validation rejects a malformed +//! spec with a structured [`Error::InvalidInput`] naming the exact field so +//! the model can self-correct, and synthesis returns a plain byte buffer. +//! +//! # What this crate deliberately does not do +//! +//! No filesystem access, no subprocesses, no async runtime, no deadline +#![cfg_attr( + feature = "docx", + doc = "handling. [`docx::generate`] is synchronous and CPU-bound. A host that runs" +)] +#![cfg_attr( + not(feature = "docx"), + doc = "handling. `docx::generate` (this build has the `docx` feature disabled) is synchronous and CPU-bound. A host that runs" +)] +//! on an async executor owns the blocking-pool hop and the timeout, because +//! only the host knows its own executor and deadline policy — and a crate that +//! guessed at either would be wrong for every other host. //! //! # 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. +//! - [`error`](self::Error) — the crate-wide [`Error`] and [`Result`]. +#![cfg_attr( + feature = "docx", + doc = "- [`docx`] — `.docx` (OOXML `WordprocessingML`) synthesis." +)] +#![cfg_attr( + not(feature = "docx"), + doc = "- `docx` (disabled in this build) — `.docx` (OOXML `WordprocessingML`) synthesis." +)] //! //! # Example //! -//! ``` -//! use rust_template::{greet, Error}; +#![cfg_attr(feature = "docx", doc = "```")] +#![cfg_attr(not(feature = "docx"), doc = "```ignore")] +//! use tinydocs::docx::{self, DocumentSection, DocumentSpec}; //! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! # Ok::<(), rust_template::Error>(()) +//! let spec = DocumentSpec { +//! title: "Weekly Report".to_string(), +//! author: Some("Ferris".to_string()), +//! sections: vec![DocumentSection { +//! heading: Some("Highlights".to_string()), +//! paragraphs: vec!["Throughput doubled.".to_string()], +//! bullets: vec!["Shipped the parser".to_string()], +//! }], +//! }; +//! +//! let bytes = docx::generate(&spec)?; +//! std::fs::write("report.docx", bytes)?; +//! # std::fs::remove_file("report.docx")?; +//! # Ok::<(), Box>(()) //! ``` //! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. +//! # Feature flags +//! +//! - `docx` (default) — `.docx` synthesis via `docx-rs`. Turning it off drops +//! the whole OOXML writer stack. mod error; -mod greeting; + +#[cfg(feature = "docx")] +pub mod docx; pub use error::{Error, Result}; -pub use greeting::greet; diff --git a/tests/public_api.rs b/tests/public_api.rs index 4ee1e4b..c271662 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -6,15 +6,62 @@ //! breaking change for users. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +// The whole public surface exercised here is `.docx` synthesis, so the file is +// empty (and must still compile) in a build without that gate. +#![cfg(feature = "docx")] -use rust_template::{Error, greet}; +use tinydocs::{ + Error, + docx::{self, DocumentSection, DocumentSpec}, +}; + +fn spec() -> DocumentSpec { + DocumentSpec { + title: "Consumer Doc".to_string(), + author: None, + sections: vec![DocumentSection { + heading: Some("Section".to_string()), + paragraphs: vec!["Body text.".to_string()], + bullets: vec!["A bullet".to_string()], + }], + } +} + +#[test] +fn consumers_can_generate_a_docx() { + let bytes = docx::generate(&spec()).expect("generation should succeed"); + assert_eq!(&bytes[0..2], b"PK"); +} + +#[test] +fn consumers_can_validate_a_spec_without_generating() { + // A host rejecting a malformed tool call at its own boundary should not + // have to pay for synthesis to find out the spec is bad. + assert!(spec().validate().is_ok()); + + let mut invalid = spec(); + invalid.sections.clear(); + assert!(matches!( + invalid.validate(), + Err(Error::InvalidInput { .. }) + )); +} #[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); +fn invalid_input_names_the_offending_field() { + let mut invalid = spec(); + invalid.title = String::new(); + match docx::generate(&invalid) { + Err(Error::InvalidInput { field, .. }) => assert_eq!(field, "title"), + other => panic!("expected InvalidInput, got {other:?}"), + } } #[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); +fn limits_are_visible_to_consumers() { + // Hosts quote these in their own tool descriptions, so they are public and + // must stay in lockstep with what validation enforces. + let mut invalid = spec(); + invalid.title = "t".repeat(docx::MAX_TEXT_CHARS + 1); + assert!(invalid.validate().is_err()); }