diff --git a/.agents/skills/docs-driven-development/SKILL.md b/.agents/skills/docs-driven-development/SKILL.md index f6af715..5f80268 100644 --- a/.agents/skills/docs-driven-development/SKILL.md +++ b/.agents/skills/docs-driven-development/SKILL.md @@ -153,8 +153,10 @@ it does not establish cross-renderer pixel equivalence. Use `just test` for the full native suite and `just test-metal` for focused capture checks. For skill or hand-authored contributor-doc changes alone, validate instructions and links; unchanged app/media output does not require regeneration. -For portable framework changes, `just doc-framework-test` provides focused SDK -and consumer checks; N3 adapter changes still require native replay and `just verify`. +Portable framework changes belong in the +[lgtmd repository](https://github.com/lgtmd/lgtmd) and use its SDK and +consumer gates. N3 adapter or dependency revision changes require native replay +and `just verify` here. ## Evolve the harness when the user story needs it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d567be..3c10599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -51,6 +53,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -90,6 +94,8 @@ jobs: path: | .cache/ci/linux-amd64/cargo/registry/cache .cache/ci/linux-amd64/cargo/registry/index + .cache/ci/linux-amd64/cargo/git/db + .cache/ci/linux-amd64/cargo/git/checkouts .cache/ci/linux-amd64/target/debug/deps .cache/ci/linux-amd64/target/debug/build .cache/ci/linux-amd64/target/debug/.fingerprint @@ -139,6 +145,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -164,6 +172,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -208,6 +218,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint @@ -259,6 +271,8 @@ jobs: path: | .cache/cargo/registry/cache .cache/cargo/registry/index + .cache/cargo/git/db + .cache/cargo/git/checkouts target/debug/deps target/debug/build target/debug/.fingerprint diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f3f81eb..c9935d6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,8 +6,6 @@ "/TODO.md", "/Cargo.lock", "/docs/guide/**", - "/crates/doc-example-config/baseline/**", - "/crates/doc-harness/docs/guide/**", "/docs/preview/vendor/**", "/vendor/**", "/examples/**", diff --git a/AGENTS.md b/AGENTS.md index e872502..e974640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,12 +69,14 @@ boundaries. The current Editor still depends on egui and is not a public kernel ## Code organization -Keep N3 as one application crate. The workspace also maintains the portable -`executable-docs` SDK in `crates/doc-harness` and its consumer examples in -`crates/doc-example-config`; N3's documentation adapter is a real SDK consumer. -This extraction is justified by a tested boundary between application replay and -document composition/publication. It does not make the editor a public kernel. -Prefer focused modules over further speculative crates or APIs. See +Keep N3 as one application crate. Its documentation adapter consumes the portable +[lgtmd SDK](https://github.com/lgtmd/lgtmd), maintained in its own repository. +N3 pins an exact Git revision until a registry release is ready. The tested +boundary separates application replay from document composition and publication; +it does not make the editor a public kernel. Keep N3's replay, renderer, scenarios, +fixtures, and guide outputs here. SDK authoring, compiler/store changes, portable +Rust/Node examples, and SDK verification belong in lgtmd. Prefer focused modules +over further speculative crates or APIs. See [executable documents](docs/architecture/executable-documents.md) for this boundary. Read [platform boundaries and performance](docs/architecture/platform-boundaries.md) @@ -83,21 +85,19 @@ semantics, keep target selection at module boundaries, and preserve each host's execution policy. Browser constraints must not lower the primary native target's capabilities, performance policy, or verification baseline. -| Location | Responsibility | -| ---------------------------- | ----------------------------------------------------------------------------- | -| `src/model/` | Authored documents, units, native text codec, geometry evaluation | -| `src/asset_io/` | Format adapters, resource resolution, native document persistence | -| `src/scene/` | Internal imported scene model, validation and pose evaluation | -| `src/scene_view.rs` | Transient asset inspection and playback; separate from edit history | -| `src/editor/` | Selection, edits, sessions, history, transform policies | -| `src/input/` | Key mapping, semantic actions, pointer and navigation routing | -| `src/render/` | Camera, wgpu rendering, derived visual feedback | -| `src/ui/` | Panels, controls, gizmo, rulers | -| `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects | -| `src/settings/` | Typed global preferences, validation, merge policy, storage interface | -| `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | -| `crates/doc-harness/` | Portable document composition, evidence, audience exports, artifact lifecycle | -| `crates/doc-example-config/` | Standalone CLI and library consumers of the documentation SDK | +| Location | Responsibility | +| -------------------- | --------------------------------------------------------------------- | +| `src/model/` | Authored documents, units, native text codec, geometry evaluation | +| `src/asset_io/` | Format adapters, resource resolution, native document persistence | +| `src/scene/` | Internal imported scene model, validation and pose evaluation | +| `src/scene_view.rs` | Transient asset inspection and playback; separate from edit history | +| `src/editor/` | Selection, edits, sessions, history, transform policies | +| `src/input/` | Key mapping, semantic actions, pointer and navigation routing | +| `src/render/` | Camera, wgpu rendering, derived visual feedback | +| `src/ui/` | Panels, controls, gizmo, rulers | +| `src/native.rs` | Window lifecycle, native events, dialogs, filesystem effects | +| `src/settings/` | Typed global preferences, validation, merge policy, storage interface | +| `src/documentation/` | Headless replay, virtual input, annotations, capture, scenarios | Keep code, identifiers, comments, tests, logs, and authored UI strings in English. Explain non-obvious behavior and platform limitations near the owning code. @@ -206,6 +206,12 @@ For changes limited to hand-authored documentation such as this file or the README, check links and factual claims; unchanged application/media output does not require rebuilding the renderer or regenerating the guide. +Verify SDK or web adapter changes in the +[lgtmd repository](https://github.com/lgtmd/lgtmd). An N3 dependency +revision update still requires native `just verify`, including real replay and +exact guide checks. Keep dependency updates explicit; do not add local path +fallbacks or discover a sibling SDK checkout automatically. + Use the root `justfile` for development commands (`just` 1.46 is the verified version). Useful commands from the repository root: diff --git a/Cargo.lock b/Cargo.lock index cc91d0d..d295f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,16 +553,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "doc-example-config" -version = "0.0.0" -dependencies = [ - "executable-docs", - "serde", - "serde_json", - "tempfile", -] - [[package]] name = "document-features" version = "0.2.12" @@ -720,7 +710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -738,18 +728,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "executable-docs" -version = "0.0.0" -dependencies = [ - "pulldown-cmark", - "same-file", - "serde", - "serde_json", - "sha2", - "tempfile", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -1375,6 +1353,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lgtmd" +version = "0.0.0" +source = "git+https://github.com/lgtmd/lgtmd?rev=8828c41eaf7fbb002044aea9f8bfd4a06ba76168#8828c41eaf7fbb002044aea9f8bfd4a06ba76168" +dependencies = [ + "pulldown-cmark", + "same-file", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "libc" version = "0.2.189" @@ -1495,7 +1485,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1520,11 +1510,11 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", - "executable-docs", "glam", "gltf", "image", "js-sys", + "lgtmd", "libc", "percent-encoding", "pollster 1.0.1", @@ -2399,7 +2389,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2730,19 +2720,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -3469,7 +3446,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 682681d..093f62e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,6 @@ edition = "2024" rust-version = "1.95" publish = false -[workspace] -members = ["crates/doc-harness", "crates/doc-example-config"] -default-members = ["."] -resolver = "3" - [features] # Contributor-only instrumentation; normal builds contain no clocks or recorder. viewport-measure = [] @@ -53,7 +48,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -executable-docs = { path = "crates/doc-harness" } +lgtmd = { git = "https://github.com/lgtmd/lgtmd", rev = "8828c41eaf7fbb002044aea9f8bfd4a06ba76168" } alacritty_terminal = "=0.26.0" egui-winit = { version = "=0.36.2", default-features = false, features = [ "clipboard", diff --git a/TODO.md b/TODO.md index ac259f5..7bf4c25 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ and document the implemented behavior in the executable user guide. | Paired selection in 2D | **Deferred:** consider verified front/back vertex pairing in aligned views; exact geometry does not prove selection intent. See [RFC 0001](rfcs/0001-paired-selection-in-2d.md). | | Grouping and hierarchy entities | Add an explicit document entity for organizing objects; evaluate groups, collections, or folders, including nesting, selection, and shared transform semantics. | | Multiple authored scenes | **Draft; implementation deferred.** Independent N3 scenes in one document/editor. Revisit [RFC 0002](rfcs/0002-authored-scenes.md), starting with a small native-scene proof of switching, history/recovery, and persistence before broader import work. | -| Reusable executable documentation framework | **Local implementation complete:** unpublished SDK, shared N3 lifecycle, Rust-authored hand-tool/navigation guides, and CLI/serde_json consumers. Next: independent developer adoption and authoring ergonomics; 26 N3 narratives still use templates. See [RFC 0003](rfcs/0003-executable-documentation-framework.md). | +| Executable guide authoring | lgtmd has moved to its [own repository](https://github.com/lgtmd/lgtmd); N3 consumes an exact Git revision. Migrate the remaining 26 N3 templates through the existing adapter when scheduled, and switch to crates.io after an explicitly authorized SDK release. See [the boundary](docs/architecture/executable-documents.md) and historical [RFC 0004](rfcs/0004-lgtmd-web-incubation.md). | | 3D format I/O | Build on read-only glTF/GLB viewing: scene export/authoring, OBJ export, compression and advanced extensions; evaluate OpenUSD composition/schema support. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Materials and PBR | Extend imported PBR viewing into editable material assignment; HDR environment import, shadows, advanced materials and color management. See the [viewer milestone](docs/milestones/modern-scene-viewer.md). | | Node Canvas UI Kit | Establish reusable UI primitives for node blocks, ports, connections, selection, dragging, pan, and zoom. Validate interactions in isolated workbench examples so future editors can adopt them confidently when a concrete consumer exists. Scope this as UI foundation work; shader/modifier semantics and evaluation remain separate. | diff --git a/crates/doc-example-config/.gitignore b/crates/doc-example-config/.gitignore deleted file mode 100644 index ace0ac9..0000000 --- a/crates/doc-example-config/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Disposable Reader/Contributor review candidates. -/.cache/ -/target/ diff --git a/crates/doc-example-config/Cargo.toml b/crates/doc-example-config/Cargo.toml deleted file mode 100644 index 586e57a..0000000 --- a/crates/doc-example-config/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "doc-example-config" -version = "0.0.0" -edition = "2024" -rust-version = "1.95" -publish = false -license = "MIT" -description = "Executable documentation consumers for a configuration CLI and serde_json" -readme = "README.md" - -[lib] -name = "config_demo" - -[[bin]] -name = "config-demo" -path = "src/main.rs" - -[dependencies] -serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" - -[dev-dependencies] -executable-docs = { path = "../doc-harness" } -tempfile = "=3.27.0" diff --git a/crates/doc-example-config/LICENSE b/crates/doc-example-config/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/crates/doc-example-config/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/doc-example-config/README.md b/crates/doc-example-config/README.md deleted file mode 100644 index 1f7e4aa..0000000 --- a/crates/doc-example-config/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Executable documentation consumers - -This package exercises the documentation SDK as a development dependency through -two concrete consumers: - -- A small configuration library and its real `config-demo` executable. Its guide - demonstrates saving valid input, rejecting an invalid replacement without - changing the output, and recovering after correction. -- The independently existing `serde_json` library. Its guide calls public APIs - to parse a typed list, inspect a type mismatch, correct the input, and serialize - the recovered values. - -The second consumer is our integration of an existing library. It does not -represent adoption or approval by the `serde_json` maintainers, and it changes no -upstream repository. - -Both guides are ordinary Rust programs containing multiline Markdown, typed -checked values, assertions, and captured resources. Each execution produces one -frozen document that can render into reader and contributor views. The contributor -view adds notes and private evidence to the same authored narrative. - -## Run the examples - -Use Rust 1.95 or later; the portable standalone gate verifies Rust 1.95.0. Keep -this package next to the SDK so the relative development dependency resolves: - -```text -crates/ - doc-harness/ - doc-example-config/ -``` - -No application renderer, graphics dependencies, service, or provider account is -required. From the `doc-example-config` directory: - -```sh -cargo build --bin config-demo -cargo run --example docs -- check --audience both -cargo run --example serde_json -- check --audience both -cargo test --all-targets -cargo clippy --all-targets -- -D warnings -``` - -The config guide finds `config-demo` beside the built example. For a separately -built executable, set `CONFIG_DEMO_BIN` to its path. Fixture files and subprocess -working directories are isolated temporary directories. The serde_json guide -needs no subprocess or fixture directory. -The configuration guide resolves the supplied CLI path before creating fixtures -or changing the subprocess directory. A missing executable fails with a useful -error even on emulated hosts that otherwise report only a child exit status. - -When a Cargo workspace supplies the lockfile, add `--locked` to these Cargo -commands. After dependencies are cached, `--offline` can verify both consumers -without network access. - -For the combined SDK and consumer development gate, run -`python3 ../doc-harness/dev/verify.py`. Its standalone mode copies both packages -and these retained baselines into a temporary workspace, independently resolving -only their dependencies. See the SDK's -[development instructions](../doc-harness/README.md#develop-and-verify-the-portable-packages) -for the minimum-toolchain check, caches, and formatting scope. - -## Review intentional documentation changes - -Generate a candidate into a new output directory, read both views and their -resources, then update the retained baseline only when the changes are intended: - -```sh -cargo run --example docs -- build --out .cache/config-review --audience both -cargo run --example serde_json -- build --out .cache/json-review --audience both - -cargo run --example docs -- update --audience both -cargo run --example serde_json -- update --audience both - -cargo run --example docs -- check --audience both -cargo run --example serde_json -- check --audience both -``` - -Choose a new candidate directory for each review; `build` preserves an existing -candidate by refusing to overwrite it. `check` is read-only. Each guide has its -own package-local baseline: - -| Guide | Authored source | Baseline | -| ----------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Configuration CLI | [Runner](examples/docs.rs) and [complete narrative and helpers](examples/support/mod.rs) | [Reader](baseline/config/reader/configure-a-port.md), [contributor](baseline/config/contributor/configure-a-port.md) | -| Typed JSON list | [Single example source](examples/serde_json.rs) | [Reader](baseline/serde-json/reader/parse-a-typed-list.md), [contributor](baseline/serde-json/contributor/parse-a-typed-list.md) | - -The configuration baseline was carried forward from the reviewed implementation -without changing its document or resource bytes. The tests check both retained -views for both consumers, repeat generation for determinism, and ensure private -contributor evidence stays out of the reader output. - -## Authoring cost and boundaries - -Measured after Rust formatting, including blank lines and comments: - -| Consumer | Complete executable authoring | Tests kept in the example | -| ----------------- | ----------------------------------------------------------------------------------: | -----------------------------: | -| Configuration CLI | 241 lines: 19 runner + 222 narrative, fixture, subprocess, and capture helper lines | 0; separate integration tests | -| serde_json | 147 lines: runner, narrative, real API calls, checks, and capture helpers | 32 additional lines; 179 total | - -There are no separate Markdown templates or hidden scenario files. The complete -example source total is 420 lines including the serde_json example's tests. The -configuration application's implementation and its separate tests are additional -code, not part of those authoring counts. - -The CLI consumer has more setup because it runs an executable and checks actual -filesystem effects. The library consumer calls existing APIs directly and needs -less infrastructure. This comparison demonstrates two integration shapes; it is -not a study of outside developers' onboarding effort. - -`Doc::resource_code` combines resource registration and placement when they occur -together. Reused resources keep a typed handle. Captured library output retains -its original bytes and producer profile; input fixtures use a distinct producer. -The diagnostic record created by `Doc::json` belongs only to contributor notes. - -The framework checks the encoded claims and retained output. These examples do -not prove that every possible input works or that every sentence is automatically -verified. Review the explanation as well as the assertions and resource changes. - -## Library provenance - -The serde_json example uses the pinned `serde_json` 1.0.151 dependency through -its public API. Its behavior is grounded in the library's official documentation: - -- [`from_str`](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) - deserializes text into the requested Rust type and reports incompatible input. -- [`Error`](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html) - exposes error categories and source locations. The guide observes a `Data` - error from a string where `u32` is expected. -- [`to_string_pretty`](https://docs.rs/serde_json/1.0.151/serde_json/fn.to_string_pretty.html) - serializes the recovered vector into formatted JSON. The guide reparses that - actual output and checks that the values are preserved. - -All narrative and integration code in these examples is authored here. The guide -captures the library's actual error message and formatted result; no upstream -guide or example source was copied. - -This package's code and documentation use the repository's MIT license, copied -verbatim into [LICENSE](LICENSE) with the original `Copyright (c) 2026 Grida` -notice. Dependency code is not vendored, and upstream dependency terms remain -unchanged. diff --git a/crates/doc-example-config/baseline/config/contributor/.ownership.json b/crates/doc-example-config/baseline/config/contributor/.ownership.json deleted file mode 100644 index 620396e..0000000 --- a/crates/doc-example-config/baseline/config/contributor/.ownership.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/configure-a-port/execution-checks.json", - "assets/configure-a-port/invalid-input.json", - "assets/configure-a-port/recovered-command.txt", - "assets/configure-a-port/rejected-command.txt", - "assets/configure-a-port/saved-configuration.json", - "assets/configure-a-port/successful-command.txt", - "assets/configure-a-port/valid-input.json", - "configure-a-port.md", - "manifest.json" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json deleted file mode 100644 index d303a7f..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "driver": "actual config-demo subprocess", - "recovered_port": 8080, - "recovery_exit": 0, - "rejected_exit": 2, - "rejected_output_unchanged": true, - "saved_port": 9090, - "success_exit": 0 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json deleted file mode 100644 index 2e086da..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 0 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt deleted file mode 100644 index a688bc2..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt deleted file mode 100644 index 41058b6..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt deleted file mode 100644 index d3e999d..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/contributor/configure-a-port.md b/crates/doc-example-config/baseline/config/contributor/configure-a-port.md deleted file mode 100644 index b746d63..0000000 --- a/crates/doc-example-config/baseline/config/contributor/configure-a-port.md +++ /dev/null @@ -1,129 +0,0 @@ -# Save a valid configuration - - - -Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together. - - - -The configuration contains one setting: port\. Choose an integer from 1 to 65535\. The Rust API's default configuration uses port 8080\. The command line reads an explicit configuration file; it does not fill in a missing port\. - - - -> **Contributor note:** This guide exercises the public Config API and the built config\-demo executable in an isolated temporary directory\. No server is started; the example checks configuration behavior\. - - - -## Save a configuration - - - -Create input\.json with this content: - - - -```json -{ - "port": 9090 -} -``` - - - -Pass the input file and an output path\. A successful command saves the validated configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] -``` - - - -The saved settings\.json now contains port 9090\. Its contents are shown below, and you can also open the [saved\-configuration](assets/configure-a-port/saved-configuration.json) file directly\. - - - -```json -{ - "port": 9090 -} -``` - - - -## Reject an invalid replacement - - - -A rejected input leaves an existing output unchanged\. To see this, replace input\.json with an invalid port: - - - -```json -{ - "port": 0 -} -``` - - - -Run the same command again\. The error explains which value needs correction: - - - -```text -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] -``` - - - -The command exits unsuccessfully and preserves every byte of settings\.json\. The [saved\-configuration](assets/configure-a-port/saved-configuration.json) from the first command is still the current configuration\. - - - -> **Contributor note on `rejected-exit`:** The CLI's rejection status is 2\. The scenario also checks that rejected input produces no success output\. - - - -> **Contributor note on `rejected-output-unchanged`:** The preservation check compares the complete output bytes before and after the rejected subprocess invocation\. - - - -## Correct the input and retry - - - -Change the port in input\.json to 8080 and rerun the command\. The same output path can now be replaced with a valid configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] -``` - - - -The output now uses port 8080\. When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds\. - - - -> **Contributor note on [rejected\-command](assets/configure-a-port/rejected-command.txt):** The transcript comes from the actual command's stdout, stderr, and exit status\. Review the [execution\-checks](assets/configure-a-port/execution-checks.json) for the recorded outcomes\. This contributor evidence is attached to the same reader artifact\. - diff --git a/crates/doc-example-config/baseline/config/contributor/manifest.json b/crates/doc-example-config/baseline/config/contributor/manifest.json deleted file mode 100644 index a525e98..0000000 --- a/crates/doc-example-config/baseline/config/contributor/manifest.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 198, - "document": "configure-a-port", - "id": "execution-checks", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/execution-checks.json", - "producer": "executable-docs/json", - "profile": "json-pretty-v1", - "sha256": "92bf03ab28d9bb016c92d37c2164782c148c639ff1e2853f7b3e9e460a6b245a" - }, - { - "bytes": 16, - "document": "configure-a-port", - "id": "invalid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/invalid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "4f52f2c12102a1a7ee628a1c571d542080f9042ad41fe10a15073b4c67c9f6af" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "recovered-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/recovered-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "60d4e34cb43759aededaee90a39a39f9758f9658670ba0f965bfb29f8aad9829" - }, - { - "bytes": 128, - "document": "configure-a-port", - "id": "rejected-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/rejected-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "b7e21baceed98258b963c4c72ad1874476a01890501d94c6c92b6a4ae7c93dc3" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "saved-configuration", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/saved-configuration.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "successful-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/successful-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "a98fc8b82913db7ed8c07955e1dcdcad55697afa2d1ae1605349c435e5553eb5" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/valid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 3796, - "document": "configure-a-port", - "kind": "page", - "mime": "text/markdown", - "path": "configure-a-port.md", - "sha256": "eec0ab6442e553848b7a4f7b87edc8ed7db1eeeb1c605cb7fc1a3de011b233f4" - } - ], - "audience": "contributor", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "paragraph", - "references": [ - { - "id": "default-port", - "kind": "value" - } - ] - }, - { - "id": "note-0001", - "kind": "note", - "references": [] - }, - { - "id": "block-0003", - "kind": "heading", - "references": [] - }, - { - "id": "block-0004", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0005", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0006", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0007", - "kind": "code", - "references": [ - { - "id": "successful-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0008", - "kind": "paragraph", - "references": [ - { - "id": "saved-port", - "kind": "value" - }, - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "code", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0010", - "kind": "heading", - "references": [] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0012", - "kind": "code", - "references": [ - { - "id": "invalid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "rejected-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "note-0002", - "kind": "note", - "references": [ - { - "id": "rejected-exit", - "kind": "value" - } - ] - }, - { - "id": "note-0003", - "kind": "note", - "references": [ - { - "id": "rejected-output-unchanged", - "kind": "claim" - } - ] - }, - { - "id": "block-0016", - "kind": "heading", - "references": [] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0018", - "kind": "code", - "references": [ - { - "id": "recovered-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0019", - "kind": "paragraph", - "references": [ - { - "id": "recovered-port", - "kind": "value" - } - ] - }, - { - "id": "note-0004", - "kind": "note", - "references": [ - { - "id": "execution-checks", - "kind": "resource" - }, - { - "id": "rejected-command", - "kind": "resource" - } - ] - } - ], - "checks": [ - { - "actual": 8080, - "expected": 8080, - "id": "default-port", - "kind": "equality", - "passed": true - }, - { - "actual": 8080, - "expected": 8080, - "id": "recovered-port", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "recovery-exit", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "recovery-stderr-empty", - "kind": "predicate", - "passed": true - }, - { - "actual": 2, - "expected": 2, - "id": "rejected-exit", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejected-output-unchanged", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejected-stdout-empty", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "rejection-explains-port-range", - "kind": "predicate", - "passed": true - }, - { - "actual": 9090, - "expected": 9090, - "id": "saved-port", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "successful-exit", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "successful-stderr-empty", - "kind": "predicate", - "passed": true - } - ], - "id": "configure-a-port", - "title": "Save a valid configuration" - } - ], - "profiles": [ - { - "producer": "config-demo", - "profile": "json-v1" - }, - { - "producer": "config-demo-cli", - "profile": "transcript-v1" - }, - { - "producer": "executable-docs/json", - "profile": "json-pretty-v1" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/config/reader/.ownership.json b/crates/doc-example-config/baseline/config/reader/.ownership.json deleted file mode 100644 index 61c1c53..0000000 --- a/crates/doc-example-config/baseline/config/reader/.ownership.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/configure-a-port/invalid-input.json", - "assets/configure-a-port/recovered-command.txt", - "assets/configure-a-port/rejected-command.txt", - "assets/configure-a-port/saved-configuration.json", - "assets/configure-a-port/successful-command.txt", - "assets/configure-a-port/valid-input.json", - "configure-a-port.md", - "manifest.json" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json deleted file mode 100644 index 2e086da..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 0 -} diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt deleted file mode 100644 index a688bc2..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt deleted file mode 100644 index 41058b6..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt deleted file mode 100644 index d3e999d..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt +++ /dev/null @@ -1,3 +0,0 @@ -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] diff --git a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json deleted file mode 100644 index a13d52b..0000000 --- a/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "port": 9090 -} diff --git a/crates/doc-example-config/baseline/config/reader/configure-a-port.md b/crates/doc-example-config/baseline/config/reader/configure-a-port.md deleted file mode 100644 index 98276d6..0000000 --- a/crates/doc-example-config/baseline/config/reader/configure-a-port.md +++ /dev/null @@ -1,113 +0,0 @@ -# Save a valid configuration - - - -Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together. - - - -The configuration contains one setting: port\. Choose an integer from 1 to 65535\. The Rust API's default configuration uses port 8080\. The command line reads an explicit configuration file; it does not fill in a missing port\. - - - -## Save a configuration - - - -Create input\.json with this content: - - - -```json -{ - "port": 9090 -} -``` - - - -Pass the input file and an output path\. A successful command saves the validated configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 9090) to settings.json. -[exit 0] -``` - - - -The saved settings\.json now contains port 9090\. Its contents are shown below, and you can also open the [saved\-configuration](assets/configure-a-port/saved-configuration.json) file directly\. - - - -```json -{ - "port": 9090 -} -``` - - - -## Reject an invalid replacement - - - -A rejected input leaves an existing output unchanged\. To see this, replace input\.json with an invalid port: - - - -```json -{ - "port": 0 -} -``` - - - -Run the same command again\. The error explains which value needs correction: - - - -```text -$ config-demo input.json --output settings.json -Configuration rejected: port must be between 1 and 65535 (received 0). -[exit 2] -``` - - - -The command exits unsuccessfully and preserves every byte of settings\.json\. The [saved\-configuration](assets/configure-a-port/saved-configuration.json) from the first command is still the current configuration\. - - - -## Correct the input and retry - - - -Change the port in input\.json to 8080 and rerun the command\. The same output path can now be replaced with a valid configuration: - - - -```text -$ config-demo input.json --output settings.json -Saved validated configuration (port 8080) to settings.json. -[exit 0] -``` - - - -The output now uses port 8080\. When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds\. - diff --git a/crates/doc-example-config/baseline/config/reader/manifest.json b/crates/doc-example-config/baseline/config/reader/manifest.json deleted file mode 100644 index 33d30df..0000000 --- a/crates/doc-example-config/baseline/config/reader/manifest.json +++ /dev/null @@ -1,310 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 16, - "document": "configure-a-port", - "id": "invalid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/invalid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "4f52f2c12102a1a7ee628a1c571d542080f9042ad41fe10a15073b4c67c9f6af" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "recovered-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/recovered-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "60d4e34cb43759aededaee90a39a39f9758f9658670ba0f965bfb29f8aad9829" - }, - { - "bytes": 128, - "document": "configure-a-port", - "id": "rejected-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/rejected-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "b7e21baceed98258b963c4c72ad1874476a01890501d94c6c92b6a4ae7c93dc3" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "saved-configuration", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/saved-configuration.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 117, - "document": "configure-a-port", - "id": "successful-command", - "kind": "resource", - "mime": "text/plain", - "path": "assets/configure-a-port/successful-command.txt", - "producer": "config-demo-cli", - "profile": "transcript-v1", - "sha256": "a98fc8b82913db7ed8c07955e1dcdcad55697afa2d1ae1605349c435e5553eb5" - }, - { - "bytes": 19, - "document": "configure-a-port", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/configure-a-port/valid-input.json", - "producer": "config-demo", - "profile": "json-v1", - "sha256": "bbcbff1eebf2fbca8dd0f1a4023d4953f70488a4a2d54a5e8d229e3727618ff2" - }, - { - "bytes": 2822, - "document": "configure-a-port", - "kind": "page", - "mime": "text/markdown", - "path": "configure-a-port.md", - "sha256": "7ce900dc5b90d729cd3808d32ae17c4d032de4e2c250ffa50a4a7f3d6a8a1a8b" - } - ], - "audience": "reader", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "paragraph", - "references": [ - { - "id": "default-port", - "kind": "value" - } - ] - }, - { - "id": "block-0003", - "kind": "heading", - "references": [] - }, - { - "id": "block-0004", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0005", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0006", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0007", - "kind": "code", - "references": [ - { - "id": "successful-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0008", - "kind": "paragraph", - "references": [ - { - "id": "saved-port", - "kind": "value" - }, - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "code", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0010", - "kind": "heading", - "references": [] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0012", - "kind": "code", - "references": [ - { - "id": "invalid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "rejected-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "saved-configuration", - "kind": "resource" - } - ] - }, - { - "id": "block-0016", - "kind": "heading", - "references": [] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0018", - "kind": "code", - "references": [ - { - "id": "recovered-command", - "kind": "resource" - } - ] - }, - { - "id": "block-0019", - "kind": "paragraph", - "references": [ - { - "id": "recovered-port", - "kind": "value" - } - ] - } - ], - "checks": [ - { - "actual": 8080, - "expected": 8080, - "id": "default-port", - "kind": "equality", - "passed": true - }, - { - "actual": 8080, - "expected": 8080, - "id": "recovered-port", - "kind": "equality", - "passed": true - }, - { - "id": "recovery-exit", - "kind": "predicate", - "passed": true - }, - { - "id": "recovery-stderr-empty", - "kind": "predicate", - "passed": true - }, - { - "id": "rejected-exit", - "kind": "equality", - "passed": true - }, - { - "id": "rejected-output-unchanged", - "kind": "predicate", - "passed": true - }, - { - "id": "rejected-stdout-empty", - "kind": "predicate", - "passed": true - }, - { - "id": "rejection-explains-port-range", - "kind": "predicate", - "passed": true - }, - { - "actual": 9090, - "expected": 9090, - "id": "saved-port", - "kind": "equality", - "passed": true - }, - { - "id": "successful-exit", - "kind": "predicate", - "passed": true - }, - { - "id": "successful-stderr-empty", - "kind": "predicate", - "passed": true - } - ], - "id": "configure-a-port", - "title": "Save a valid configuration" - } - ], - "profiles": [ - { - "producer": "config-demo", - "profile": "json-v1" - }, - { - "producer": "config-demo-cli", - "profile": "transcript-v1" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json b/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json deleted file mode 100644 index a2c5e90..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/parse-a-typed-list/error-observation.json", - "assets/parse-a-typed-list/formatted-list.json", - "assets/parse-a-typed-list/mismatched-input.json", - "assets/parse-a-typed-list/type-error.txt", - "assets/parse-a-typed-list/valid-input.json", - "manifest.json", - "parse-a-typed-list.md" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json deleted file mode 100644 index 67baec7..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "category": "Data", - "column": 11, - "line": 1, - "message": "invalid type: string \"three\", expected u32 at line 1 column 11" -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json deleted file mode 100644 index aa50563..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - 2, - 3, - 5 -] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json deleted file mode 100644 index a47bcf6..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, "three", 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt deleted file mode 100644 index 8da8dd3..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt +++ /dev/null @@ -1 +0,0 @@ -invalid type: string "three", expected u32 at line 1 column 11 \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json deleted file mode 100644 index 3ec03bc..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, 3, 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/contributor/manifest.json b/crates/doc-example-config/baseline/serde-json/contributor/manifest.json deleted file mode 100644 index 8cb137a..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/manifest.json +++ /dev/null @@ -1,314 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 135, - "document": "parse-a-typed-list", - "id": "error-observation", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/error-observation.json", - "producer": "executable-docs/json", - "profile": "json-pretty-v1", - "sha256": "ceb55f83bc7cfa0f01ca0bf120ec95581583c60eb741b388b83f80e2740b85fa" - }, - { - "bytes": 17, - "document": "parse-a-typed-list", - "id": "formatted-list", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/formatted-list.json", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "80c83a54da142957b5a2b7b8ec8f8396d6cdb2daee1848129b19dba82c88c909" - }, - { - "bytes": 15, - "document": "parse-a-typed-list", - "id": "mismatched-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/mismatched-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "e14fac803c516c4f81b9af6ac11db65e751802c0c495db102f14aa9df774458b" - }, - { - "bytes": 62, - "document": "parse-a-typed-list", - "id": "type-error", - "kind": "resource", - "mime": "text/plain", - "path": "assets/parse-a-typed-list/type-error.txt", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "755ae51b66058706677805b97a062f14ecd1895103bd1dc942782a097aa7acd6" - }, - { - "bytes": 9, - "document": "parse-a-typed-list", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/valid-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "40d28069123e2a7d89dd143fd210b66f8ed7fb19ebfc23032c051f4f6aed0381" - }, - { - "bytes": 2904, - "document": "parse-a-typed-list", - "kind": "page", - "mime": "text/markdown", - "path": "parse-a-typed-list.md", - "sha256": "1d31773366db1e8979454d03c15026a3abdee6a5d32f22a6cc5234a14fc82802" - } - ], - "audience": "contributor", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "heading", - "references": [] - }, - { - "id": "block-0003", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0004", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0005", - "kind": "paragraph", - "references": [ - { - "id": "parsed-length", - "kind": "value" - }, - { - "id": "parsed-sum", - "kind": "value" - } - ] - }, - { - "id": "block-0006", - "kind": "heading", - "references": [] - }, - { - "id": "block-0007", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0008", - "kind": "code", - "references": [ - { - "id": "mismatched-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0010", - "kind": "code", - "references": [ - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [ - { - "id": "error-line", - "kind": "value" - }, - { - "id": "error-column", - "kind": "value" - } - ] - }, - { - "id": "note-0001", - "kind": "note", - "references": [ - { - "id": "error-observation", - "kind": "resource" - }, - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0012", - "kind": "heading", - "references": [] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "corrected-values", - "kind": "value" - } - ] - }, - { - "id": "block-0016", - "kind": "code", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "note-0002", - "kind": "note", - "references": [] - } - ], - "checks": [ - { - "actual": [ - 2, - 3, - 5 - ], - "expected": [ - 2, - 3, - 5 - ], - "id": "corrected-values", - "kind": "equality", - "passed": true - }, - { - "actual": 11, - "expected": 11, - "id": "error-column", - "kind": "equality", - "passed": true - }, - { - "actual": 1, - "expected": 1, - "id": "error-line", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "mismatched-input-has-valid-json-syntax", - "kind": "predicate", - "passed": true - }, - { - "actual": 3, - "expected": 3, - "id": "parsed-length", - "kind": "equality", - "passed": true - }, - { - "actual": 10, - "expected": 10, - "id": "parsed-sum", - "kind": "equality", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "pretty-json-preserves-values", - "kind": "predicate", - "passed": true - }, - { - "actual": true, - "expected": true, - "id": "type-mismatch-is-data-error", - "kind": "predicate", - "passed": true - } - ], - "id": "parse-a-typed-list", - "title": "Parse a typed JSON list" - } - ], - "profiles": [ - { - "producer": "executable-docs/json", - "profile": "json-pretty-v1" - }, - { - "producer": "serde-json-guide", - "profile": "json-fixture-v1" - }, - { - "producer": "serde_json", - "profile": "serde-json-1.0.151" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md b/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md deleted file mode 100644 index 3cbc9bf..0000000 --- a/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md +++ /dev/null @@ -1,102 +0,0 @@ -# Parse a typed JSON list - - - -Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html). - - - -## Parse a valid list - - - -Start with this JSON array: - - - -```json -[2, 3, 5] -``` - - - -Parsing succeeds with 3 integers\. Their sum is 10\. The values are ordinary Rust integers after deserialization\. - - - -## Inspect a type mismatch - - - -Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`: - - - -```json -[2, "three", 5] -``` - - - -The failed parse returns an error\. Its display text identifies the incompatible value and the expected Rust type: - - - -```text -invalid type: string "three", expected u32 at line 1 column 11 -``` - - - -The error is classified as Data and is reported at line 1, column 11\. Use the category to distinguish a type mismatch from invalid JSON syntax\. - - - -> **Contributor note on [type\-error](assets/parse-a-typed-list/type-error.txt):** The error text is captured from serde\_json 1\.0\.151\. A dependency update must review both the asserted category and the captured message\. The [error\-observation](assets/parse-a-typed-list/error-observation.json) records the observed diagnostic fields for contributors\. - - - -## Correct the input and serialize the result - - - -Replace the string with the number 3 and parse the corrected source again\. The corrected input is the same valid array shown above: - - - -```json -[2, 3, 5] -``` - - - -The corrected parse returns \[2,3,5\]\. Pass that vector to serde\_json::to\_string\_pretty to obtain formatted JSON: - - - -```json -[ - 2, - 3, - 5 -] -``` - - - -The [formatted\-list](assets/parse-a-typed-list/formatted-list.json) can be parsed back into the same integers\. Formatting changes the presentation while preserving these values\. - - - -> **Contributor note:** This integration is maintained here to test the documentation framework against an independently existing Rust library\. It is not upstream serde\_json documentation or evidence of adoption by its maintainers\. - diff --git a/crates/doc-example-config/baseline/serde-json/reader/.ownership.json b/crates/doc-example-config/baseline/serde-json/reader/.ownership.json deleted file mode 100644 index a493293..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/.ownership.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schema_version": 1, - "files": [ - "assets/parse-a-typed-list/formatted-list.json", - "assets/parse-a-typed-list/mismatched-input.json", - "assets/parse-a-typed-list/type-error.txt", - "assets/parse-a-typed-list/valid-input.json", - "manifest.json", - "parse-a-typed-list.md" - ] -} \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json deleted file mode 100644 index aa50563..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - 2, - 3, - 5 -] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json deleted file mode 100644 index a47bcf6..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, "three", 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt deleted file mode 100644 index 8da8dd3..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt +++ /dev/null @@ -1 +0,0 @@ -invalid type: string "three", expected u32 at line 1 column 11 \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json deleted file mode 100644 index 3ec03bc..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json +++ /dev/null @@ -1 +0,0 @@ -[2, 3, 5] \ No newline at end of file diff --git a/crates/doc-example-config/baseline/serde-json/reader/manifest.json b/crates/doc-example-config/baseline/serde-json/reader/manifest.json deleted file mode 100644 index cc722f6..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/manifest.json +++ /dev/null @@ -1,274 +0,0 @@ -{ - "artifacts": [ - { - "bytes": 17, - "document": "parse-a-typed-list", - "id": "formatted-list", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/formatted-list.json", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "80c83a54da142957b5a2b7b8ec8f8396d6cdb2daee1848129b19dba82c88c909" - }, - { - "bytes": 15, - "document": "parse-a-typed-list", - "id": "mismatched-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/mismatched-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "e14fac803c516c4f81b9af6ac11db65e751802c0c495db102f14aa9df774458b" - }, - { - "bytes": 62, - "document": "parse-a-typed-list", - "id": "type-error", - "kind": "resource", - "mime": "text/plain", - "path": "assets/parse-a-typed-list/type-error.txt", - "producer": "serde_json", - "profile": "serde-json-1.0.151", - "sha256": "755ae51b66058706677805b97a062f14ecd1895103bd1dc942782a097aa7acd6" - }, - { - "bytes": 9, - "document": "parse-a-typed-list", - "id": "valid-input", - "kind": "resource", - "mime": "application/json", - "path": "assets/parse-a-typed-list/valid-input.json", - "producer": "serde-json-guide", - "profile": "json-fixture-v1", - "sha256": "40d28069123e2a7d89dd143fd210b66f8ed7fb19ebfc23032c051f4f6aed0381" - }, - { - "bytes": 2261, - "document": "parse-a-typed-list", - "kind": "page", - "mime": "text/markdown", - "path": "parse-a-typed-list.md", - "sha256": "92f2e8c2837c2111f329fbb6f532e14c2bb4b09d4910c9ced0e624f143e6bee7" - } - ], - "audience": "reader", - "documents": [ - { - "blocks": [ - { - "id": "block-0001", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0002", - "kind": "heading", - "references": [] - }, - { - "id": "block-0003", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0004", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0005", - "kind": "paragraph", - "references": [ - { - "id": "parsed-length", - "kind": "value" - }, - { - "id": "parsed-sum", - "kind": "value" - } - ] - }, - { - "id": "block-0006", - "kind": "heading", - "references": [] - }, - { - "id": "block-0007", - "kind": "markdown", - "references": [] - }, - { - "id": "block-0008", - "kind": "code", - "references": [ - { - "id": "mismatched-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0009", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0010", - "kind": "code", - "references": [ - { - "id": "type-error", - "kind": "resource" - } - ] - }, - { - "id": "block-0011", - "kind": "paragraph", - "references": [ - { - "id": "error-line", - "kind": "value" - }, - { - "id": "error-column", - "kind": "value" - } - ] - }, - { - "id": "block-0012", - "kind": "heading", - "references": [] - }, - { - "id": "block-0013", - "kind": "paragraph", - "references": [] - }, - { - "id": "block-0014", - "kind": "code", - "references": [ - { - "id": "valid-input", - "kind": "resource" - } - ] - }, - { - "id": "block-0015", - "kind": "paragraph", - "references": [ - { - "id": "corrected-values", - "kind": "value" - } - ] - }, - { - "id": "block-0016", - "kind": "code", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - }, - { - "id": "block-0017", - "kind": "paragraph", - "references": [ - { - "id": "formatted-list", - "kind": "resource" - } - ] - } - ], - "checks": [ - { - "actual": [ - 2, - 3, - 5 - ], - "expected": [ - 2, - 3, - 5 - ], - "id": "corrected-values", - "kind": "equality", - "passed": true - }, - { - "actual": 11, - "expected": 11, - "id": "error-column", - "kind": "equality", - "passed": true - }, - { - "actual": 1, - "expected": 1, - "id": "error-line", - "kind": "equality", - "passed": true - }, - { - "id": "mismatched-input-has-valid-json-syntax", - "kind": "predicate", - "passed": true - }, - { - "actual": 3, - "expected": 3, - "id": "parsed-length", - "kind": "equality", - "passed": true - }, - { - "actual": 10, - "expected": 10, - "id": "parsed-sum", - "kind": "equality", - "passed": true - }, - { - "id": "pretty-json-preserves-values", - "kind": "predicate", - "passed": true - }, - { - "id": "type-mismatch-is-data-error", - "kind": "predicate", - "passed": true - } - ], - "id": "parse-a-typed-list", - "title": "Parse a typed JSON list" - } - ], - "profiles": [ - { - "producer": "serde-json-guide", - "profile": "json-fixture-v1" - }, - { - "producer": "serde_json", - "profile": "serde-json-1.0.151" - } - ], - "schema_version": 1 -} diff --git a/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md b/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md deleted file mode 100644 index dae6fbc..0000000 --- a/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md +++ /dev/null @@ -1,94 +0,0 @@ -# Parse a typed JSON list - - - -Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html). - - - -## Parse a valid list - - - -Start with this JSON array: - - - -```json -[2, 3, 5] -``` - - - -Parsing succeeds with 3 integers\. Their sum is 10\. The values are ordinary Rust integers after deserialization\. - - - -## Inspect a type mismatch - - - -Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`: - - - -```json -[2, "three", 5] -``` - - - -The failed parse returns an error\. Its display text identifies the incompatible value and the expected Rust type: - - - -```text -invalid type: string "three", expected u32 at line 1 column 11 -``` - - - -The error is classified as Data and is reported at line 1, column 11\. Use the category to distinguish a type mismatch from invalid JSON syntax\. - - - -## Correct the input and serialize the result - - - -Replace the string with the number 3 and parse the corrected source again\. The corrected input is the same valid array shown above: - - - -```json -[2, 3, 5] -``` - - - -The corrected parse returns \[2,3,5\]\. Pass that vector to serde\_json::to\_string\_pretty to obtain formatted JSON: - - - -```json -[ - 2, - 3, - 5 -] -``` - - - -The [formatted\-list](assets/parse-a-typed-list/formatted-list.json) can be parsed back into the same integers\. Formatting changes the presentation while preserving these values\. - diff --git a/crates/doc-example-config/examples/docs.rs b/crates/doc-example-config/examples/docs.rs deleted file mode 100644 index 73cab64..0000000 --- a/crates/doc-example-config/examples/docs.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! This example is both the authored guide and its executable evidence producer. -//! Build `config-demo` first so the guide can exercise the actual CLI. - -mod support; - -use executable_docs::runner::run_cli; -use std::path::Path; - -fn main() { - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/config"); - let result = run_cli(std::env::args().skip(1), &baseline, || { - let binary = support::locate_cli()?; - support::guide(&binary) - }); - if let Err(error) = result { - eprintln!("{error}"); - std::process::exit(1); - } -} diff --git a/crates/doc-example-config/examples/serde_json.rs b/crates/doc-example-config/examples/serde_json.rs deleted file mode 100644 index 634fce5..0000000 --- a/crates/doc-example-config/examples/serde_json.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! An executable user explanation built against the existing serde_json crate. -//! This is our integration example, not documentation adopted by its maintainers. - -use executable_docs::runner::run_cli; -use executable_docs::{Artifact, Doc, Document, Result}; -use serde_json::error::Category; -use std::path::Path; - -fn main() { - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/serde-json"); - if let Err(error) = run_cli(std::env::args().skip(1), &baseline, guide) { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn guide() -> Result> { - let mut doc = Doc::new("parse-a-typed-list", "Parse a typed JSON list")?; - doc.markdown( - r#"Use `serde_json::from_str::>()` to read a JSON array of -unsigned integers into a Rust vector. The Rust type supplies a concrete -expectation: a string inside the array is a type mismatch, even if the JSON -syntax is valid. - -This walkthrough parses a valid list, inspects an error, and retries with -corrected input. It calls the public `serde_json` API directly. - -The library documents [deserializing from a string](https://docs.rs/serde_json/1.0.151/serde_json/fn.from_str.html) -and [classifying errors](https://docs.rs/serde_json/1.0.151/serde_json/struct.Error.html)."#, - )?; - - doc.heading(2, "Parse a valid list")?; - let source = "[2, 3, 5]"; - doc.paragraph("Start with this JSON array:")?; - let input = doc.resource_code("valid-input", input_json(source)?, "json")?; - let values: Vec = serde_json::from_str(source).map_err(|error| error.to_string())?; - let count = doc.expect_eq("parsed-length", values.len(), 3)?; - let total = doc.expect_eq("parsed-sum", values.iter().sum::(), 10)?; - doc.paragraph(( - "Parsing succeeds with ", - &count, - " integers. Their sum is ", - &total, - ". The values are ordinary Rust integers after deserialization.", - ))?; - - doc.heading(2, "Inspect a type mismatch")?; - doc.markdown( - r#"Replace the second number with a string. This input is still valid JSON, -but it does not match `Vec`:"#, - )?; - let invalid = r#"[2, "three", 5]"#; - doc.resource_code("mismatched-input", input_json(invalid)?, "json")?; - doc.require( - "mismatched-input-has-valid-json-syntax", - serde_json::from_str::(invalid).is_ok(), - )?; - let error = match serde_json::from_str::>(invalid) { - Ok(_) => return Err("Expected the string element to fail typed deserialization".into()), - Err(error) => error, - }; - doc.require( - "type-mismatch-is-data-error", - error.classify() == Category::Data, - )?; - let line = doc.expect_eq("error-line", error.line(), 1)?; - let column = doc.expect_eq("error-column", error.column(), 11)?; - doc.paragraph("The failed parse returns an error. Its display text identifies the incompatible value and the expected Rust type:")?; - let diagnostic = doc.resource_code( - "type-error", - captured(&error.to_string(), "text/plain", "txt")?, - "text", - )?; - doc.paragraph(( - "The error is classified as Data and is reported at line ", - &line, - ", column ", - &column, - ". Use the category to distinguish a type mismatch from invalid JSON syntax.", - ))?; - let details = doc.json( - "error-observation", - &serde_json::json!({ - "category": format!("{:?}", error.classify()), - "line": error.line(), - "column": error.column(), - "message": error.to_string(), - }), - )?; - doc.note_on( - &diagnostic, - ( - "The error text is captured from serde_json 1.0.151. A dependency update must review both the asserted category and the captured message. The ", - &details, - " records the observed diagnostic fields for contributors.", - ), - )?; - - doc.heading(2, "Correct the input and serialize the result")?; - doc.paragraph("Replace the string with the number 3 and parse the corrected source again. The corrected input is the same valid array shown above:")?; - doc.code(&input, "json")?; - let corrected = invalid.replace("\"three\"", "3"); - let recovered: Vec = - serde_json::from_str(&corrected).map_err(|error| error.to_string())?; - let recovered_values = doc.expect_eq("corrected-values", &recovered, &values)?; - doc.paragraph(( - "The corrected parse returns ", - &recovered_values, - ". Pass that vector to serde_json::to_string_pretty to obtain formatted JSON:", - ))?; - let pretty = serde_json::to_string_pretty(&recovered).map_err(|error| error.to_string())?; - let roundtrip: Vec = serde_json::from_str(&pretty).map_err(|error| error.to_string())?; - doc.require("pretty-json-preserves-values", roundtrip == recovered)?; - let formatted = doc.resource_code( - "formatted-list", - captured(&pretty, "application/json", "json")?, - "json", - )?; - doc.paragraph(( - "The ", - &formatted, - " can be parsed back into the same integers. Formatting changes the presentation while preserving these values.", - ))?; - doc.note("This integration is maintained here to test the documentation framework against an independently existing Rust library. It is not upstream serde_json documentation or evidence of adoption by its maintainers.")?; - Ok(vec![doc.finish()?]) -} - -fn captured(text: &str, mime: &str, extension: &str) -> Result { - Artifact::new( - text.as_bytes(), - mime, - extension, - "serde_json", - "serde-json-1.0.151", - ) -} - -fn input_json(text: &str) -> Result { - Artifact::new( - text.as_bytes(), - "application/json", - "json", - "serde-json-guide", - "json-fixture-v1", - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use executable_docs::{Audience, store}; - - #[test] - fn both_serde_json_views_match_the_retained_contract() { - let documents = guide().unwrap(); - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/serde-json"); - for (audience, directory) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let files = Document::render_many(&documents, audience).unwrap(); - store::check(&baseline.join(directory), &files).unwrap(); - } - } - - #[test] - fn library_guide_repeats_exactly_without_exposing_contributor_diagnostics() { - let first = Document::render_many(&guide().unwrap(), Audience::Reader).unwrap(); - let second = Document::render_many(&guide().unwrap(), Audience::Reader).unwrap(); - assert_eq!(first, second); - assert!(!first.keys().any(|path| path.contains("error-observation"))); - let reader_text = first - .values() - .map(|bytes| String::from_utf8_lossy(bytes)) - .collect::>() - .join("\n"); - assert!(!reader_text.contains("observed diagnostic fields for contributors")); - } -} diff --git a/crates/doc-example-config/examples/support/mod.rs b/crates/doc-example-config/examples/support/mod.rs deleted file mode 100644 index 577830a..0000000 --- a/crates/doc-example-config/examples/support/mod.rs +++ /dev/null @@ -1,222 +0,0 @@ -use config_demo::Config; -use executable_docs::{Artifact, Doc, Document, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; - -/// Resolve a built CLI without knowing the repository or target directory. -pub fn locate_cli() -> Result { - if let Some(binary) = std::env::var_os("CONFIG_DEMO_BIN") { - return fs::canonicalize(binary) - .map_err(|error| format!("CONFIG_DEMO_BIN could not be resolved: {error}")); - } - let example = std::env::current_exe().map_err(|error| error.to_string())?; - let directory = example - .parent() - .and_then(Path::parent) - .ok_or("could not locate the example's build directory")?; - let binary = directory.join(format!("config-demo{}", std::env::consts::EXE_SUFFIX)); - if !binary.is_file() { - return Err("Build the actual CLI with `cargo build -p doc-example-config --bin config-demo`, or set CONFIG_DEMO_BIN to its path.".into()); - } - Ok(binary) -} - -/// Author prose beside the operations and checks which support its promises. -pub fn guide(binary: &Path) -> Result> { - // Resolve before changing the child's directory. Some emulated hosts report - // a missing executable as child exit 127 instead of a process-spawn error. - let binary = fs::canonicalize(binary).map_err(|error| { - format!( - "could not run the config-demo CLI at {}: {error}", - binary.display() - ) - })?; - let directory = tempfile::tempdir().map_err(|error| error.to_string())?; - let output_path = directory.path().join("settings.json"); - let mut doc = Doc::new("configure-a-port", "Save a valid configuration")?; - - doc.markdown( - r#"Use **config-demo** to validate a JSON configuration before saving it. -The command reports a useful error when a value is invalid and keeps the last -valid output available while you correct the input. - -This walkthrough shows how to: - -- Save a valid configuration. -- Reject an invalid replacement without changing the saved file. -- Correct the input and retry successfully. - -Run the commands from a scratch directory so that `input.json` and -`settings.json` stay together."#, - )?; - let default_port = doc.expect_eq("default-port", Config::default().port, 8080)?; - doc.paragraph(( - "The configuration contains one setting: port. Choose an integer from 1 to 65535. The Rust API's default configuration uses port ", - &default_port, - ". The command line reads an explicit configuration file; it does not fill in a missing port.", - ))?; - doc.note("This guide exercises the public Config API and the built config-demo executable in an isolated temporary directory. No server is started; the example checks configuration behavior.")?; - - doc.heading(2, "Save a configuration")?; - let valid_input = b"{\n \"port\": 9090\n}\n"; - write_fixture(directory.path(), "input.json", valid_input)?; - let valid_source = doc.resource("valid-input", json_artifact(valid_input.to_vec())?)?; - doc.paragraph("Create input.json with this content:")?; - doc.code(&valid_source, "json")?; - - let successful = invoke(&binary, directory.path(), "input.json")?; - doc.require("successful-exit", successful.output.status.success())?; - doc.require( - "successful-stderr-empty", - successful.output.stderr.is_empty(), - )?; - let success_transcript = doc.resource( - "successful-command", - text_artifact(successful.transcript.into_bytes())?, - )?; - doc.paragraph("Pass the input file and an output path. A successful command saves the validated configuration:")?; - doc.code(&success_transcript, "text")?; - - let saved_bytes = fs::read(&output_path).map_err(|error| error.to_string())?; - let saved = Config::load(&output_path).map_err(|error| error.to_string())?; - let saved_port = doc.expect_eq("saved-port", saved.port, 9090)?; - let saved_artifact = - doc.resource("saved-configuration", json_artifact(saved_bytes.clone())?)?; - doc.paragraph(( - "The saved settings.json now contains port ", - &saved_port, - ". Its contents are shown below, and you can also open the ", - &saved_artifact, - " file directly.", - ))?; - doc.code(&saved_artifact, "json")?; - - doc.heading(2, "Reject an invalid replacement")?; - doc.paragraph("A rejected input leaves an existing output unchanged. To see this, replace input.json with an invalid port:")?; - let invalid_input = b"{\n \"port\": 0\n}\n"; - write_fixture(directory.path(), "input.json", invalid_input)?; - let invalid_source = doc.resource("invalid-input", json_artifact(invalid_input.to_vec())?)?; - doc.code(&invalid_source, "json")?; - - let rejected = invoke(&binary, directory.path(), "input.json")?; - let rejection_code = doc.expect_eq("rejected-exit", rejected.exit_code, 2)?; - doc.require("rejected-stdout-empty", rejected.output.stdout.is_empty())?; - doc.require( - "rejection-explains-port-range", - String::from_utf8_lossy(&rejected.output.stderr) - .contains("port must be between 1 and 65535 (received 0)"), - )?; - let after_rejection = fs::read(&output_path).map_err(|error| error.to_string())?; - let preservation = doc.require("rejected-output-unchanged", after_rejection == saved_bytes)?; - let rejection_transcript = doc.resource( - "rejected-command", - text_artifact(rejected.transcript.into_bytes())?, - )?; - doc.paragraph("Run the same command again. The error explains which value needs correction:")?; - doc.code(&rejection_transcript, "text")?; - doc.paragraph(( - "The command exits unsuccessfully and preserves every byte of settings.json. The ", - &saved_artifact, - " from the first command is still the current configuration.", - ))?; - doc.note_on( - &rejection_code, - "The CLI's rejection status is 2. The scenario also checks that rejected input produces no success output.", - )?; - doc.note_on( - &preservation, - "The preservation check compares the complete output bytes before and after the rejected subprocess invocation.", - )?; - - doc.heading(2, "Correct the input and retry")?; - doc.paragraph("Change the port in input.json to 8080 and rerun the command. The same output path can now be replaced with a valid configuration:")?; - let recovery_input = b"{\n \"port\": 8080\n}\n"; - write_fixture(directory.path(), "input.json", recovery_input)?; - let recovered = invoke(&binary, directory.path(), "input.json")?; - doc.require("recovery-exit", recovered.output.status.success())?; - doc.require("recovery-stderr-empty", recovered.output.stderr.is_empty())?; - let recovery_config = Config::load(&output_path).map_err(|error| error.to_string())?; - let recovered_port = doc.expect_eq("recovered-port", recovery_config.port, 8080)?; - let recovery_transcript = doc.resource( - "recovered-command", - text_artifact(recovered.transcript.into_bytes())?, - )?; - doc.code(&recovery_transcript, "text")?; - doc.paragraph(( - "The output now uses port ", - &recovered_port, - ". When validation fails, correct the input and retry; the last valid output remains available until the correction succeeds.", - ))?; - - let diagnostics = serde_json::json!({ - "driver": "actual config-demo subprocess", - "success_exit": successful.output.status.code(), - "rejected_exit": rejected.output.status.code(), - "recovery_exit": recovered.output.status.code(), - "rejected_output_unchanged": after_rejection == saved_bytes, - "saved_port": saved.port, - "recovered_port": recovery_config.port, - }); - let diagnostic_resource = doc.json("execution-checks", &diagnostics)?; - doc.note_on( - &rejection_transcript, - ( - "The transcript comes from the actual command's stdout, stderr, and exit status. Review the ", - &diagnostic_resource, - " for the recorded outcomes. This contributor evidence is attached to the same reader artifact.", - ), - )?; - - Ok(vec![doc.finish()?]) -} - -fn write_fixture(directory: &Path, name: &str, bytes: &[u8]) -> Result<()> { - fs::write(directory.join(name), bytes).map_err(|error| error.to_string()) -} - -struct Invocation { - output: Output, - exit_code: i32, - transcript: String, -} - -fn invoke(binary: &Path, directory: &Path, input: &str) -> Result { - let arguments = [input, "--output", "settings.json"]; - let output = Command::new(binary) - .current_dir(directory) - .args(arguments) - .output() - .map_err(|error| format!("could not run the config-demo CLI: {error}"))?; - let stdout = std::str::from_utf8(&output.stdout).map_err(|error| error.to_string())?; - let stderr = std::str::from_utf8(&output.stderr).map_err(|error| error.to_string())?; - let status = output - .status - .code() - .ok_or("config-demo terminated without an exit code")?; - // Only the executable's display name differs from the actual invocation. - // Arguments and working-directory-relative paths are preserved verbatim. - let transcript = format!( - "$ config-demo {}\n{stdout}{stderr}[exit {status}]\n", - arguments.join(" ") - ); - Ok(Invocation { - output, - exit_code: status, - transcript, - }) -} - -fn json_artifact(bytes: Vec) -> Result { - Artifact::new(bytes, "application/json", "json", "config-demo", "json-v1") -} - -fn text_artifact(bytes: Vec) -> Result { - Artifact::new( - bytes, - "text/plain", - "txt", - "config-demo-cli", - "transcript-v1", - ) -} diff --git a/crates/doc-example-config/src/lib.rs b/crates/doc-example-config/src/lib.rs deleted file mode 100644 index edacb25..0000000 --- a/crates/doc-example-config/src/lib.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! A small configuration API used to exercise executable user documentation. - -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// The application configuration. Values are validated before saving. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Config { - pub port: u32, -} - -impl Default for Config { - fn default() -> Self { - Self { port: 8080 } - } -} - -impl Config { - pub fn validate(&self) -> Result<(), ConfigError> { - if (1..=65535).contains(&self.port) { - Ok(()) - } else { - Err(ConfigError::InvalidPort(self.port)) - } - } - - pub fn from_json(source: &str) -> Result { - let config: Self = serde_json::from_str(source).map_err(ConfigError::Json)?; - config.validate()?; - Ok(config) - } - - pub fn load(path: &Path) -> Result { - let source = fs::read_to_string(path).map_err(|error| ConfigError::Io { - operation: "read", - path: path.to_owned(), - error, - })?; - Self::from_json(&source) - } - - /// Validate first, then replace the output with a complete adjacent file. - /// A rejected configuration never creates or changes the output file. - pub fn save(&self, output: &Path) -> Result<(), ConfigError> { - self.validate()?; - let mut bytes = serde_json::to_vec_pretty(self).map_err(ConfigError::Json)?; - bytes.push(b'\n'); - write_atomic(output, &bytes).map_err(|error| ConfigError::Io { - operation: "save", - path: output.to_owned(), - error, - }) - } -} - -#[derive(Debug)] -pub enum ConfigError { - InvalidPort(u32), - Json(serde_json::Error), - Io { - operation: &'static str, - path: PathBuf, - error: io::Error, - }, -} - -impl fmt::Display for ConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidPort(port) => write!( - formatter, - "port must be between 1 and 65535 (received {port})." - ), - Self::Json(error) => write!(formatter, "invalid configuration JSON: {error}"), - Self::Io { - operation, - path, - error, - } => write!( - formatter, - "could not {operation} {}: {error}", - path.display() - ), - } - } -} - -impl std::error::Error for ConfigError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Json(error) => Some(error), - Self::Io { error, .. } => Some(error), - Self::InvalidPort(_) => None, - } - } -} - -static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0); - -struct PendingFile(PathBuf); - -impl Drop for PendingFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } -} - -fn write_atomic(output: &Path, bytes: &[u8]) -> io::Result<()> { - let parent = output - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - // Adjacent temporary files keep the final rename on one filesystem. Exclusive - // creation preserves unrelated files even if a previous process left one behind. - for _ in 0..128 { - let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); - let pending = parent.join(format!( - ".config-demo-{}-{sequence}.tmp", - std::process::id() - )); - let mut file = match OpenOptions::new() - .write(true) - .create_new(true) - .open(&pending) - { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - }; - let pending = PendingFile(pending); - file.write_all(bytes)?; - file.sync_all()?; - drop(file); - fs::rename(&pending.0, output)?; - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate an adjacent temporary file", - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_the_documented_port_bounds() { - for port in [1, 8080, 65535] { - assert!(Config { port }.validate().is_ok()); - } - for port in [0, 65536, u32::MAX] { - assert!(matches!( - Config { port }.validate(), - Err(ConfigError::InvalidPort(actual)) if actual == port - )); - } - } - - #[test] - fn parsing_rejects_unknown_fields_and_non_integer_ports() { - assert!(Config::from_json(r#"{"port":8080,"prot":9090}"#).is_err()); - assert!(Config::from_json(r#"{"port":80.5}"#).is_err()); - assert!(Config::from_json(r#"{"port":-1}"#).is_err()); - assert!(Config::from_json("{}").is_err()); - } - - #[test] - fn rejected_save_preserves_original_bytes_and_leaves_no_temporary_file() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - let original = b"{\"port\":8080}\n"; - fs::write(&output, original).unwrap(); - - assert!(Config { port: 0 }.save(&output).is_err()); - assert_eq!(fs::read(&output).unwrap(), original); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } - - #[test] - fn valid_save_replaces_the_complete_document() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - fs::write(&output, b"previous output").unwrap(); - - Config { port: 9090 }.save(&output).unwrap(); - assert_eq!(Config::load(&output).unwrap(), Config { port: 9090 }); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } - - #[test] - fn failed_replacement_cleans_up_its_temporary_file() { - let directory = tempfile::tempdir().unwrap(); - let output = directory.path().join("settings.json"); - fs::create_dir(&output).unwrap(); - fs::write(output.join("keep.txt"), "existing data").unwrap(); - - assert!(Config { port: 9090 }.save(&output).is_err()); - assert_eq!( - fs::read_to_string(output.join("keep.txt")).unwrap(), - "existing data" - ); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); - } -} diff --git a/crates/doc-example-config/src/main.rs b/crates/doc-example-config/src/main.rs deleted file mode 100644 index 75d21bf..0000000 --- a/crates/doc-example-config/src/main.rs +++ /dev/null @@ -1,40 +0,0 @@ -use config_demo::Config; -use std::ffi::OsString; -use std::path::PathBuf; -use std::process::ExitCode; - -const USAGE: &str = "Usage: config-demo INPUT.json --output OUTPUT.json"; - -fn main() -> ExitCode { - match run(std::env::args_os().skip(1).collect()) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - } -} - -fn run(arguments: Vec) -> Result<(), String> { - if arguments.len() == 1 && arguments[0] == "--help" { - println!("{USAGE}"); - println!("Validate a configuration and save it only when its port is valid."); - return Ok(()); - } - if arguments.len() != 3 || arguments[1] != "--output" { - return Err(USAGE.to_owned()); - } - let input = PathBuf::from(&arguments[0]); - let output = PathBuf::from(&arguments[2]); - let config = - Config::load(&input).map_err(|error| format!("Configuration rejected: {error}"))?; - config - .save(&output) - .map_err(|error| format!("Configuration not saved: {error}"))?; - println!( - "Saved validated configuration (port {}) to {}.", - config.port, - output.display() - ); - Ok(()) -} diff --git a/crates/doc-example-config/tests/cli.rs b/crates/doc-example-config/tests/cli.rs deleted file mode 100644 index 5915e74..0000000 --- a/crates/doc-example-config/tests/cli.rs +++ /dev/null @@ -1,50 +0,0 @@ -use config_demo::Config; -use std::fs; -use std::process::{Command, Output}; - -fn run(directory: &std::path::Path, input: &str) -> Output { - Command::new(env!("CARGO_BIN_EXE_config-demo")) - .current_dir(directory) - .args([input, "--output", "settings.json"]) - .output() - .unwrap() -} - -#[test] -fn actual_cli_rejects_without_replacing_and_recovers_after_correction() { - let directory = tempfile::tempdir().unwrap(); - let input = directory.path().join("input.json"); - let output = directory.path().join("settings.json"); - fs::write(&input, r#"{"port":9090}"#).unwrap(); - - let success = run(directory.path(), "input.json"); - assert!(success.status.success()); - assert_eq!(Config::load(&output).unwrap().port, 9090); - let original = fs::read(&output).unwrap(); - - fs::write(&input, r#"{"port":0}"#).unwrap(); - let rejected = run(directory.path(), "input.json"); - assert_eq!(rejected.status.code(), Some(2)); - assert!(rejected.stdout.is_empty()); - assert!( - String::from_utf8(rejected.stderr) - .unwrap() - .contains("port must be between 1 and 65535") - ); - assert_eq!(fs::read(&output).unwrap(), original); - - fs::write(&input, r#"{"port":8080}"#).unwrap(); - let recovered = run(directory.path(), "input.json"); - assert!(recovered.status.success()); - assert_eq!(Config::load(&output).unwrap().port, 8080); - assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); -} - -#[test] -fn malformed_input_does_not_create_output() { - let directory = tempfile::tempdir().unwrap(); - fs::write(directory.path().join("input.json"), "{unfinished").unwrap(); - let rejected = run(directory.path(), "input.json"); - assert_eq!(rejected.status.code(), Some(2)); - assert!(!directory.path().join("settings.json").exists()); -} diff --git a/crates/doc-example-config/tests/guide.rs b/crates/doc-example-config/tests/guide.rs deleted file mode 100644 index c77a9dc..0000000 --- a/crates/doc-example-config/tests/guide.rs +++ /dev/null @@ -1,62 +0,0 @@ -#[path = "../examples/support/mod.rs"] -#[allow(dead_code)] -mod support; - -use executable_docs::{Audience, Document, store}; -use std::path::Path; - -#[test] -fn guide_is_reproducible_and_keeps_contributor_evidence_out_of_reader_output() { - let binary = Path::new(env!("CARGO_BIN_EXE_config-demo")); - let first = support::guide(binary).unwrap(); - let second = support::guide(binary).unwrap(); - let reader = Document::render_many(&first, Audience::Reader).unwrap(); - let repeated = Document::render_many(&second, Audience::Reader).unwrap(); - assert_eq!(reader, repeated); - - let reader_text = reader - .iter() - .filter(|(path, _)| path.ends_with(".md")) - .map(|(_, bytes)| std::str::from_utf8(bytes).unwrap()) - .collect::>() - .join("\n"); - assert!(reader_text.contains("preserves every byte")); - assert!(reader_text.contains("[exit 2]")); - assert!(!reader_text.contains("contributor evidence")); - assert!(!reader.keys().any(|path| path.contains("execution-checks"))); - - let contributor = Document::render_many(&first, Audience::Contributor).unwrap(); - assert!( - contributor - .keys() - .any(|path| path.contains("execution-checks")) - ); - let contributor_text = contributor - .iter() - .filter(|(path, _)| path.ends_with(".md")) - .map(|(_, bytes)| std::str::from_utf8(bytes).unwrap()) - .collect::>() - .join("\n"); - assert!(contributor_text.contains("contributor evidence")); -} - -#[test] -fn guide_fails_if_the_actual_cli_cannot_run() { - let directory = tempfile::tempdir().unwrap(); - let error = support::guide(&directory.path().join("missing-config-demo")).unwrap_err(); - assert!(error.contains("could not run"), "Unexpected error: {error}"); -} - -#[test] -fn both_config_views_match_the_retained_contract() { - let binary = Path::new(env!("CARGO_BIN_EXE_config-demo")); - let documents = support::guide(binary).unwrap(); - let baseline = Path::new(env!("CARGO_MANIFEST_DIR")).join("baseline/config"); - for (audience, directory) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let files = Document::render_many(&documents, audience).unwrap(); - store::check(&baseline.join(directory), &files).unwrap(); - } -} diff --git a/crates/doc-harness/.gitignore b/crates/doc-harness/.gitignore deleted file mode 100644 index bd30896..0000000 --- a/crates/doc-harness/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Disposable standalone verification output and local review candidates. -/.cache/ -/target/ -__pycache__/ diff --git a/crates/doc-harness/Cargo.toml b/crates/doc-harness/Cargo.toml deleted file mode 100644 index 75874e2..0000000 --- a/crates/doc-harness/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "executable-docs" -version = "0.0.0" -edition = "2024" -rust-version = "1.95" -publish = false -license = "MIT" -description = "Rust-authored executable documents with checked observations and owned artifacts" -readme = "README.md" - -[dependencies] -serde = { version = "=1.0.229", features = ["derive"] } -serde_json = "=1.0.151" -sha2 = "=0.10.9" -same-file = "=1.0.6" -pulldown-cmark = { version = "=0.13.0", default-features = false, features = [ - "html", -] } - -[dev-dependencies] -tempfile = "=3.27.0" diff --git a/crates/doc-harness/LICENSE b/crates/doc-harness/LICENSE deleted file mode 100644 index 943658a..0000000 --- a/crates/doc-harness/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Grida - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/doc-harness/README.md b/crates/doc-harness/README.md deleted file mode 100644 index ce4e280..0000000 --- a/crates/doc-harness/README.md +++ /dev/null @@ -1,380 +0,0 @@ -# Executable documents - -Author a document while executing the behavior it explains. Ordinary Rust code -supplies the actions and checks; the document collects prose, frozen observations, -resources, and contributor notes. The same completed document can produce Reader -and Contributor exports without executing the application again. - -This package is maintained in the N3 repository, with local consumers. Its -API and manifest are experimental, and `publish = false` prevents registry -publication. It does not depend on N3, egui, wgpu, a renderer, an agent skill, or a -documentation website. - -## Start with an executable paragraph - -The example below is also a Rust documentation test. It needs only this crate: - -```rust -use executable_docs::{Audience, Doc, Document}; - -let mut doc = Doc::new("trim-text", "Trim surrounding whitespace")?; -doc.heading(2, "Normalize a string")?; - -let normalized = " hello\n".trim(); -let result = doc.expect_eq("trimmed-value", normalized, "hello")?; -doc.paragraph(("Trimming the input produces ", &result, "."))?; - -let output = doc.text_code("output", normalized, "text")?; -doc.note_on(&output, "The fixture includes spaces and a trailing newline.")?; - -let document = doc.finish()?; -let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader)?; -let contributor = Document::render_many(&[document], Audience::Contributor)?; -assert_eq!(reader["assets/trim-text/output.txt"], b"hello"); -assert!(!String::from_utf8_lossy(&reader["trim-text.md"]).contains("fixture")); -assert!(String::from_utf8_lossy(&contributor["trim-text.md"]).contains("fixture")); -# Ok::<(), String>(()) -``` - -Add this package as a development dependency through its local path. A standalone -copy of this directory retains explicit Cargo dependency versions, this README, -tests, and its license; no workspace-inherited package fields are required. - -## Authoring and evidence - -- `heading`, `paragraph`, and `markdown` append document blocks. Paragraph tuples - combine literal text with typed references without a separate template or - placeholder registry. Plain paragraph text is escaped, while `markdown` accepts - CommonMark with tables and strikethrough. -- `heading_with_id(id, level, text)` assigns a stable section anchor when that - section needs durable references. `Block::link(label)` gives those references - reader-facing labels; the returned block also accepts attached notes. Named - sections share the checked evidence namespace, so collisions fail explicitly. -- `markdown_parts` combines authored Markdown with typed references and preserves - the supplied whitespace, including the absence of a trailing newline. Use it - when an existing document needs exact composition. `binding` freezes a host's - observed text, code label, or key sequence; its typed handle emits escaped text, - ``, or `` markup. The host must validate control witnesses and - canonical key mappings. A binding is not a behavioral assertion. -- `expect_eq(id, actual, expected)` executes an equality check and freezes its - serialized observation. Its returned value handle can appear in prose. - `require(id, condition)` records a predicate check. Meaningful checks remain the - author's responsibility; the framework cannot prove arbitrary prose. -- `resource(id, artifact)` registers owned bytes. `code` or `embed` places an - existing resource. `resource_code` registers and places text while retaining its - custom MIME, producer and comparison profile. The same handle can be reused in - prose, placed again, or annotated without rerunning its producer. - `Resource::image(alt)` and `Resource::link(label)` provide inline views; - image views require a declared image MIME type. `resource_at` preserves an - existing safe artifact path, while complete exports reject duplicate owners - across documents and generated pages. -- `text`, `json`, `text_code`, and `json_code` are conveniences for the built-in - UTF-8 and deterministic JSON encoders. Their producer labels identify those - encoders. Use `Artifact::new` or `Artifact::from_file` when application or external - producer attribution matters. File bytes are read immediately, not at export. -- `note` appends contributor commentary; `note_on` attaches it to a block, check, - checked value, or resource. Notes have their own block counter, so adding notes - cannot renumber reader blocks. Reader exports omit notes, their exclusive - resources, and their relations and producer profiles. - -Handles belong to one document run. Duplicate evidence IDs, foreign handles, -wrong text types, unreferenced resources, and failed checks fail explicitly. -Ignoring a failed builder operation does not make the document valid: it remains -tainted and `finish()` rejects it. A completed document needs at least one -successful check. Public block IDs are currently positional; inserting public -content can change later block anchors. Check and resource IDs are author-owned. - -`finish()` freezes and validates the authored document independently of its future -file layout. Export then validates resource use, page and artifact paths, local -links, and the complete selected audience. A finished document alone is not a -publishable bundle; every export performs those remaining checks before returning -bytes, and the runner prepares every requested export before publication begins. - -For readable inline sentences, the optional named form of `prose!` keeps the -text together and inserts typed handles. It returns a result because missing, -duplicate, unused, or malformed bindings fail rather than silently dropping text -or evidence. Use `{{` and `}}` for literal braces; format specifiers are not -supported. Each binding expression runs once, even when its name appears more -than once. This composition lives in the scenario, without a separate template. - -```rust -use executable_docs::{prose, Doc}; - -let mut doc = Doc::new("retry", "Retry an operation")?; -let section = doc.heading_with_id("retry-limit", 2, "Choose a retry limit")?; -let count = doc.expect_eq("attempts", 3, 3)?; -doc.paragraph(prose!("The operation takes {count} attempts.", count = &count)?)?; -doc.paragraph(("See ", section.link("the retry limit"), "."))?; -doc.note_on(§ion, "Keep the section ID when moving this explanation.")?; -let document = doc.finish()?; -# Ok::<(), String>(()) -``` - -The expression-list form of `prose!` accepts any number of ordinary Rust -expressions and works with `paragraph`, `markdown_parts`, and notes. Tuples remain -convenient for shorter sequences. Prefer ordinary blocks for new documents; -`markdown_parts` preserves exact Markdown bytes for an existing host format. - -```rust -use executable_docs::{prose, Doc}; - -let mut doc = Doc::new("retry-limit", "Retry limit")?; -let count = doc.expect_eq("attempts", 3, 3)?; -doc.markdown_parts(prose!["The operation takes **", &count, "** attempts.\n"])?; -let document = doc.finish()?; -# Ok::<(), String>(()) -``` - -Markdown references are parsed with `pulldown-cmark`, including reference-style -links, escaping, inline code and fenced code. Definitions resolve within each -`markdown` or `markdown_parts` block. Literal raw HTML and unresolved references -fail; typed code/key bindings generate only their escaped, predefined HTML. -The completed page is parsed again: generated tags must remain tags, authored -HTML must remain excluded, and code fences or other containers must not swallow -later document blocks. Place captured code with `code`/`resource_code`; do not -wrap typed bindings in Markdown code delimiters. Typed artifact destinations are -URL-encoded without changing their owned filesystem names. -Local links must resolve -within the actual audience's exported bundle; fragments refer to generated block -IDs, not downstream website heading slugs. HTTP(S) and `mailto:` links are allowed -without checking remote availability. An artifact's declared MIME type does not -validate its binary contents, and producer metadata is attribution rather than -cryptographic proof of origin. - -JSON resources and frozen equality observations recursively sort object keys, -including when a consumer enables `serde_json/preserve_order`. Array order is -preserved. Final manifests use the same canonical object ordering. - -## Exports, comparison and publication - -`Document::render_many` returns a sorted map of relative paths to bytes: Markdown -pages, resources under `assets//.`, and -`manifest.json`. It freezes no additional application state and performs no -filesystem publication. The package's small `runner` module accepts a generation -callback and exposes `check`, `build --out PATH`, and `update`, with Reader, -Contributor, or both audiences. The `store` module owns those filesystem effects. - -### Configure your project's layout - -The SDK does not discover a repository, read Cargo metadata, or prescribe where -document functions, fixtures, skills, or application binaries live. Your callback -supplies completed documents. Configure export paths separately from authoring: - -```rust,no_run -use executable_docs::{Audience, Doc, ExportLayout, runner::RunnerConfig}; -# let project_root = std::env::current_dir().unwrap(); - -let layout = ExportLayout::default() - .pages_under("reference")? - .resources_under("downloads")? - .page("setup", "manual/getting-started/index.md")?; - -let config = RunnerConfig::new(&project_root)? - .output(Audience::Reader, "website/generated", "public")? - .output(Audience::Contributor, "review/evidence", "internal")? - .layout(layout); - -executable_docs::runner::run_cli_with(std::env::args().skip(1), &config, || { - let mut doc = Doc::new("setup", "Set up the application")?; - let value = doc.expect_eq("trimmed", " ready ".trim(), "ready")?; - doc.paragraph(("The normalized value is ", &value, "."))?; - Ok(vec![doc.finish()?]) -})?; -# Ok::<(), String>(()) -``` - -`project_root` is a path chosen by your host. `RunnerConfig::new` captures that -base once; relative baseline paths and CLI `--out` paths resolve against it. -Absolute destinations may be outside the project. The base need not contain a -Cargo manifest or Git repository, and the runner never changes the working -directory. Fixture paths and process working directories remain the callback's -responsibility. - -Each `output` selects an audience, its retained baseline, and its relative -directory inside a fresh build destination. In this example, `update` writes -`website/generated` and `review/evidence` beneath the chosen base; -`build --out candidate` writes `candidate/public` and `candidate/internal`. -An empty build directory name exports a single selected audience directly at -the build root. Configured CLI runs select all configured audiences by default; -`default_audiences` or `--audience` can narrow that selection. Call -`runner::run(&config, RunMode::Check, &[Audience::Reader], generate)` to integrate -with a test, build tool, or your own CLI parser. The convenience `run_cli` keeps -its original `reader`/`contributor` directories and reader-only default. - -`pages_under` and `resources_under` accept an empty prefix for the bundle root. -Per-document `page` overrides are exact bundle-relative paths; they do not inherit -the page prefix. `Doc::resource_at` paths are also exact and do not inherit the -resource prefix. Page contents remain Markdown regardless of the chosen filename. -Typed resource links are calculated relative to the actual page location. -Authored Markdown links use that same page-relative convention: `..` may reach -another location within the bundle but cannot escape it. Missing targets, unknown -page overrides, duplicate paths, and file/directory collisions fail, including -collisions with contributor-only resources. - -For in-memory integration, use `Document::render_many_with(documents, audience, -&layout)` or `document.render_with(audience, &layout)`. No filesystem runner is -required. The configurable layout belongs to full exports; fragment adapters keep -their existing explicit resource paths and host-owned page assembly. - -The [configured example](examples/configured.rs) demonstrates host-owned fixtures, -external baselines, nested pages, renamed audience directories, and private -resources. Its positional arguments are `PROJECT_ROOT BASELINE_ROOT`, followed by -the standard SDK commands. The fixture convention in that example belongs to its -host, not the framework. - -Configuration does not weaken the evidence contract. The manifest filename -`manifest.json`, store metadata `.ownership.json`, audience rules, exact comparison, -and path-safety checks remain fixed. Retained audience trees must be separate, and -build output must not overlap any configured baseline. Moving an owned page is an -explicit retirement of its previous path, not silent deletion. -Each output also reserves adjacent `.NAME.lock`, `.NAME.staging`, and -`.NAME.backup` paths. The runner rejects configurations that place another output -inside those transaction paths before generation, including fresh build roots. -Artifact inventory keys always use `/`, independently of host path separators. - -Before creating audience trees, the runner also rejects paths that could alias on -a case-insensitive or Unicode-normalizing filesystem. Existing directories are -compared by filesystem identity. Where separation depends on missing directory -names, use distinct ASCII names such as `public` and `internal`; case alone is not -a distinction. Unicode roots and suffixes are supported, but if Unicode names are -the only distinction, create the separate directories first so their identities -can be checked. Missing names ending in dots or spaces, or containing `~`, also -cannot establish separation because some filesystems treat them as aliases. -This conservative preflight is read-only and does not guess the -filesystem's normalization policy. It builds on `lifecycle::ensure_separate_paths`, -which checks ancestry and existing directory identities. These checks assume a -cooperative filesystem; they do not reserve paths against another process changing -them after preflight. - -Checking compares the complete owned tree without writing. Building requires a -fresh destination separate from the retained baseline. Updating is explicit and -stages the complete candidate before replacing a compatible owned destination. -Ordinary publication failures preserve or restore a backup; this is not a -crash-atomic transaction. Multiple audiences publish independently. A successful -update records candidate bytes; it does not establish human review or approve a -release. Sites, navigation, styling, review policy, and application drivers belong -to each consumer. - -An existing host can use `Document::render_fragment` to retain its page title, -artifact paths and formatting. A fragment adds no title or block anchors, and -rejects typed block links that would point to omitted anchors. The host owns -whole-bundle link validation for that export. Handle ownership, image kinds, -resource accounting, and audience exclusions still apply. This adapter seam -does not weaken ordinary `render_many` bundle checking. -`Document::resource_files(audience)` returns only that audience's frozen resource -paths and bytes, without a page or manifest. Fragment adapters must apply the same -audience to both text and copied files so contributor-only resources stay private. - -The lower-level `lifecycle` module shares preparation, complete tree comparison, -path validation, staging, publication, and recovery with hosts that retain their -own manifest format. `Ownership::Dedicated` adds no metadata to a host-owned tree -and refuses unowned preexisting files; `Ownership::Recorded` retains explicit -ownership metadata for SDK exports. Host compatibility rules are explicit -callbacks. The structured `store` wrapper validates the SDK schema, identities, -references, complete artifact inventory, byte lengths and hashes, audience, and -producer/profile metadata before publication. Checks and recovery also validate -the retained tree. Updating permits an explicitly removed obsolete artifact only -when the complete candidate also omits that path; retained bytes must still match -their receipt. The generic lifecycle does not interpret application evidence or -claim that every byte is reproducible across different environments. - -Manifest schema version 1 records: - -| Field | Meaning | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `schema_version`, `audience` | Interpretation and visibility policy for this export. | -| `profiles` | Sorted producer/profile pairs for exported resources. | -| `documents` | Document IDs, titles, visible block IDs and reference relations, executed checks, and any visible typed bindings. | -| `artifacts` | Every exported page and resource except the manifest itself, including byte length and SHA-256; resources also identify owner, MIME, producer and profile. | - -Check records contain their ID, kind, and passed status. Contributor records also -contain observed and expected values. Reader records include those payloads only -when a public typed reference uses the value; check IDs and status are still -public. Treat evidence IDs as public metadata. Profiles describe the producer's -comparison conditions and must be chosen honestly by the adapter; they are not a -complete environment fingerprint. The manifest contains no run timestamp, -absolute source path, or process ID. Full tree comparison checks the manifest's -bytes as well as artifact bytes; a manifest cannot contain its own final hash. - -The initial schema is not a cross-language execution protocol or a promised -long-term compatibility standard. See the source and tests for its current -failure semantics. No crate release, registry publication, or repository split is -implied by this maintained local package. - -### Review exported documents - -The `preview` example renders a valid SDK export into a new disposable HTML tree. -It validates the source's complete receipt and ownership, then uses artifact -identities to convert pages, resolve page links, and preserve download bytes. -Custom page extensions work; a Markdown resource remains a download. Tables and -strikethrough use the same options as compilation. Page/resource output collisions -fail before writing. Reader previews contain only Reader-exported resources. - -After generating the [consumer example's candidate](../doc-example-config/README.md#review-intentional-documentation-changes), -run from this package directory: - -```sh -cargo run --example preview -- ../doc-example-config/.cache/config-review/reader .cache/config-reader-preview -cargo run --example preview -- ../doc-example-config/.cache/config-review/contributor .cache/config-contributor-preview -``` - -Open the resulting HTML pages in a browser and inspect both audiences and their -resources before explicitly accepting a baseline. The preview requires a new -destination and omits receipt metadata because its HTML differs from the exported -Markdown. It does not update the export or accept a baseline. - -## Develop and verify the portable packages - -From this package directory, run the package-owned GPU-free gate: - -```sh -python3 dev/verify.py -python3 dev/verify.py --standalone --toolchain 1.95.0 --offline -``` - -The first command uses the containing workspace's existing lockfile and host -toolchain. It checks Rust formatting for only the SDK and consumer packages, -warnings-denied Clippy, all targets including examples and exact Reader/Contributor -baselines, both packages' doctests, and warnings-denied API documentation. It also -runs the development harness's Python regressions. Python 3 and Rust with rustfmt -and Clippy are required; no N3 setup command or graphics adapter is involved. -N3's `just doc-framework-test` and its CI invoke this same gate. - -The standalone command copies both sibling packages, tests, examples, baselines, -and license notices into a disposable minimal workspace. It resolves a temporary -lockfile independently of N3's lockfile, patches, assets, and dependencies, then -runs the same gate on the explicitly selected installed toolchain. Rust 1.95.0 -is the declared and verified minimum. `just doc-framework-smoke --offline` runs -this check from N3. Omit `--offline` to allow dependency resolution when caches -are cold. Temporary resolution can select different compatible transitive -versions from N3's lockfile; its lockfile is deleted with the temporary workspace. -No second permanent workspace or lockfile is maintained here. - -Explicit `CARGO_HOME`, `CARGO_TARGET_DIR`, and offline environment settings are -preserved. Relative cache paths retain their meaning from the invocation directory -when checks change their working directory; absolute paths and empty overrides -are passed through. Without a target override, standalone builds reuse the ignored -package-local `.cache/standalone-target` directory. Omitting `--toolchain` uses -the installed Cargo toolchain selected in the temporary workspace; it does not -inherit N3's toolchain pin. - -Authored documentation consists of the two package READMEs and Rust documentation -comments. Rust formatting belongs to this gate. In N3, the authored READMEs use -`just fmt-docs`; the portable gate adds no Node.js requirement. Generated example -baselines, guide pages, manifests, and resources are excluded from prose -formatting and change only through explicit candidate review and baseline update. -Independent repository extraction will carry this development harness and both -packages together. - -## License and provenance - -The package originates in N3's executable documentation harness and its local -framework experiment. It uses the repository's existing MIT license, copied -verbatim into the packaged `LICENSE` file so a standalone copy preserves its notice. -The notice retains `Copyright (c) 2026 Grida`. Dependency licenses remain those of -their respective packages; no dependency code is vendored here. -The pinned direct dependency manifests declare MIT for `pulldown-cmark`, -`Unlicense/MIT` for `same-file`, and -MIT OR Apache-2.0 for `serde`, `serde_json`, `sha2`, and the `tempfile` development -dependency. This records the current source metadata rather than changing any -upstream terms. diff --git a/crates/doc-harness/dev/tests/test_verify.py b/crates/doc-harness/dev/tests/test_verify.py deleted file mode 100644 index 0466136..0000000 --- a/crates/doc-harness/dev/tests/test_verify.py +++ /dev/null @@ -1,133 +0,0 @@ -import contextlib -import importlib.util -import io -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - - -SPEC = importlib.util.spec_from_file_location("portable_verify", Path(__file__).resolve().parents[1] / "verify.py") -verify = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(verify) - - -class PortableVerificationTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) / "host with spaces $(not-a-command)" - self.sdk = self.root / "crates/doc-harness" - self.example = self.root / "crates/doc-example-config" - for package in (self.sdk, self.example): - (package / "src").mkdir(parents=True) - (package / "src/lib.rs").write_text("// Portable source.\n") - (package / "Cargo.toml").write_text('[package]\nlicense = "MIT"\n') - (package / "README.md").write_text("# Package\n") - (package / "LICENSE").write_text("MIT License\nCopyright notice\n") - - def test_copy_preserves_baselines_and_license_without_host_dependencies_or_output(self): - (self.root / "Cargo.toml").write_text("host patches and graphics dependencies\n") - (self.root / "Cargo.lock").write_text("host lockfile\n") - (self.root / "rust-toolchain.toml").write_text("host toolchain pin\n") - baseline = self.example / "baseline/reader" - baseline.mkdir(parents=True) - (baseline / "manifest.json").write_bytes(b'{"exact":"baseline"}\n') - (baseline / ".ownership.json").write_bytes(b'{"owned":"receipt"}\n') - download = baseline / "assets/target/Cargo.lock" - download.parent.mkdir(parents=True) - download.write_bytes(b"An owned download, not a build lockfile.\n") - private_download = baseline / "assets/.cache/__pycache__/receipt.txt" - private_download.parent.mkdir(parents=True) - private_download.write_bytes(b"Owned bytes also survive cache-like names.\n") - for excluded in (".cache", "target", "__pycache__"): - (self.sdk / excluded).mkdir() - (self.sdk / excluded / "disposable").write_text("ignored\n") - (self.sdk / "Cargo.lock").write_text("package lockfile is not portable input\n") - destination = self.root.parent / "isolated" - copied = verify.payload(self.sdk, destination) - self.assertEqual(copied, destination / "crates/doc-harness") - self.assertNotIn("host", (destination / "Cargo.toml").read_text()) - self.assertFalse((destination / "Cargo.lock").exists()) - self.assertFalse((destination / "rust-toolchain.toml").exists()) - for excluded in (".cache", "target", "__pycache__", "Cargo.lock"): - self.assertFalse((copied / excluded).exists()) - for package in ("doc-harness", "doc-example-config"): - self.assertEqual((destination / "crates" / package / "LICENSE").read_bytes(), b"MIT License\nCopyright notice\n") - copied_baseline = destination / "crates/doc-example-config/baseline/reader" - for name in ("manifest.json", ".ownership.json"): - self.assertEqual((copied_baseline / name).read_bytes(), (baseline / name).read_bytes()) - self.assertEqual((copied_baseline / "assets/target/Cargo.lock").read_bytes(), download.read_bytes()) - self.assertEqual((copied_baseline / "assets/.cache/__pycache__/receipt.txt").read_bytes(), private_download.read_bytes()) - - def test_copy_fails_if_provenance_is_missing(self): - (self.example / "LICENSE").unlink() - with self.assertRaisesRegex(OSError, "LICENSE"): - verify.payload(self.sdk, self.root.parent / "isolated") - - def test_standalone_resolves_a_temporary_lock_and_failure_stops_the_gate(self): - calls = [] - - def fail_resolution(command, *, cwd, environment): - calls.append((command, cwd, environment)) - self.assertTrue((cwd.parent.parent / "Cargo.toml").exists()) - self.assertFalse((cwd.parent.parent / "Cargo.lock").exists()) - return 17 - - with patch.object(verify, "run", side_effect=fail_resolution): - self.assertEqual(verify.main(["--standalone", "--toolchain", "1.95.0", "--offline"], sdk=self.sdk), 17) - self.assertEqual(calls[0][0], ["cargo", "+1.95.0", "generate-lockfile", "--offline"]) - self.assertEqual(len(calls), 1) - self.assertFalse(calls[0][1].exists()) - self.assertFalse((self.root / "Cargo.lock").exists()) - - def test_failing_consumer_checks_stop_before_doctests_and_docs(self): - calls = [] - - def fail_tests(command, *, cwd, environment): - calls.append(command) - return 19 if "--all-targets" in command and "test" in command else 0 - - with patch.object(verify, "run", side_effect=fail_tests): - self.assertEqual(verify.main([], sdk=self.sdk), 19) - self.assertEqual(len(calls), 4) - self.assertFalse(any("doc" in command or "--doc" in command for command in calls)) - - def test_environment_keeps_explicit_caches_and_doc_flags_without_mutating_caller(self): - environment = {"CARGO_TARGET_DIR": "/explicit target", "CARGO_HOME": "/explicit cargo", "RUSTDOCFLAGS": "--cfg custom"} - with patch.dict(os.environ, environment, clear=True): - prepared = verify.environment_for(self.sdk, True) - self.assertEqual(prepared["CARGO_TARGET_DIR"], "/explicit target") - self.assertEqual(prepared["CARGO_HOME"], "/explicit cargo") - self.assertNotIn("PYTHONDONTWRITEBYTECODE", os.environ) - with patch.object(verify, "run", return_value=0) as run: - self.assertEqual(verify.checks(self.sdk, ["cargo"], prepared, False), 0) - docs = run.call_args.kwargs["environment"] - self.assertEqual(docs["RUSTDOCFLAGS"], "--cfg custom -D warnings") - self.assertEqual(prepared["RUSTDOCFLAGS"], "--cfg custom") - - def test_relative_caches_keep_the_invocation_location_when_check_cwd_changes(self): - for standalone in (False, True): - with self.subTest(standalone=standalone), patch.dict(os.environ, { - "CARGO_HOME": "cache with spaces $(not-a-command)", - "CARGO_TARGET_DIR": "../shared target", - }, clear=True), patch.object(verify.Path, "cwd", return_value=self.root): - prepared = verify.environment_for(self.sdk, standalone) - self.assertEqual(prepared["CARGO_HOME"], str(self.root / "cache with spaces $(not-a-command)")) - self.assertEqual(prepared["CARGO_TARGET_DIR"], str(self.root / "../shared target")) - self.assertEqual(os.environ["CARGO_HOME"], "cache with spaces $(not-a-command)") - with patch.dict(os.environ, {"CARGO_HOME": "", "CARGO_TARGET_DIR": ""}, clear=True): - prepared = verify.environment_for(self.sdk, True) - self.assertEqual(prepared["CARGO_HOME"], "") - self.assertEqual(prepared["CARGO_TARGET_DIR"], "") - - def test_missing_cargo_fails_visibly(self): - output = io.StringIO() - with contextlib.redirect_stderr(output), patch.object(verify, "run", side_effect=FileNotFoundError("cargo unavailable")): - self.assertEqual(verify.main([], sdk=self.sdk), 1) - self.assertIn("cargo unavailable", output.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/crates/doc-harness/dev/verify.py b/crates/doc-harness/dev/verify.py deleted file mode 100644 index 8523521..0000000 --- a/crates/doc-harness/dev/verify.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Verify the portable Rust SDK and its real consumers without application replay.""" - -import argparse -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile - - -SDK = Path(__file__).resolve().parents[1] -PACKAGES = ("executable-docs", "doc-example-config") -WORKSPACE = """[workspace] -members = ["crates/doc-harness", "crates/doc-example-config"] -resolver = "3" -""" - - -def status(returncode): - return returncode if returncode >= 0 else 128 - returncode - - -def run(command, *, cwd, environment): - # Argument lists preserve paths and toolchain names as data, including spaces. - print("Portable check: " + " ".join(command), flush=True) - return status(subprocess.run(command, cwd=cwd, env=environment, check=False).returncode) - - -def payload(sdk, destination): - """Copy the maintained packages, without the host workspace or disposable output.""" - for source in (sdk, sdk.parent / "doc-example-config"): - for required in ("Cargo.toml", "README.md", "LICENSE", "src"): - if not (source / required).exists(): - raise OSError(f"Portable payload is missing {source / required}") - def disposable(directory, names): - directory = Path(directory) - excluded = set() - if directory == source: - excluded.update((".cache", "target", "Cargo.lock", "__pycache__")) - if directory == source / "dev" or source / "dev" in directory.parents: - excluded.add("__pycache__") - return excluded.intersection(names) - - shutil.copytree( - source, - destination / "crates" / source.name, - ignore=disposable, - ) - (destination / "Cargo.toml").write_text(WORKSPACE, encoding="utf-8") - return destination / "crates/doc-harness" - - -def environment_for(sdk, standalone): - environment = os.environ.copy() - environment["PYTHONDONTWRITEBYTECODE"] = "1" - invocation = Path.cwd() - for variable in ("CARGO_HOME", "CARGO_TARGET_DIR"): - value = environment.get(variable) - if value and not Path(value).is_absolute(): - # Cargo interprets these against its working directory. Capture the - # caller's location before native/standalone checks change that cwd. - environment[variable] = str(invocation / value) - if standalone: - # Keep rebuilds cheap while preserving explicit native/container overrides. - environment.setdefault("CARGO_TARGET_DIR", str(sdk / ".cache/standalone-target")) - return environment - - -def checks(sdk, cargo, environment, offline): - selection = [part for package in PACKAGES for part in ("-p", package)] - locked = ["--locked", *(["--offline"] if offline else [])] - commands = [ - [sys.executable, "-m", "unittest", "discover", "-s", str(sdk / "dev/tests"), "-p", "test_*.py"], - [*cargo, "fmt", *selection, "--check"], - [*cargo, "clippy", *locked, *selection, "--all-targets", "--", "-D", "warnings"], - [*cargo, "test", *locked, *selection, "--all-targets"], - [*cargo, "test", *locked, *selection, "--doc"], - [*cargo, "doc", *locked, *selection, "--no-deps"], - ] - for command in commands: - command_environment = environment - if command[:len(cargo) + 1] == [*cargo, "doc"]: - command_environment = environment.copy() - command_environment["RUSTDOCFLAGS"] = environment.get("RUSTDOCFLAGS", "") + " -D warnings" - result = run(command, cwd=sdk, environment=command_environment) - if result: - return result - return 0 - - -def main(arguments=None, *, sdk=SDK): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--standalone", action="store_true", help="copy both packages to a disposable minimal workspace") - parser.add_argument("--toolchain", help="select an installed rustup toolchain, e.g. 1.95.0") - parser.add_argument("--offline", action="store_true", help="resolve and verify using cached Cargo dependencies") - options = parser.parse_args(arguments) - cargo = ["cargo", *([f"+{options.toolchain}"] if options.toolchain else [])] - try: - sdk = Path(sdk).resolve() - environment = environment_for(sdk, options.standalone) - if not options.standalone: - return checks(sdk, cargo, environment, options.offline) - with tempfile.TemporaryDirectory(prefix="executable-docs-") as temporary: - isolated = payload(sdk, Path(temporary)) - # Resolve only portable dependencies into a temporary lockfile. Never - # copy or modify the application's lockfile, patches, or toolchain pin. - result = run( - [*cargo, "generate-lockfile", *(["--offline"] if options.offline else [])], - cwd=isolated, environment=environment, - ) - if result: - return result - return checks(isolated, cargo, environment, options.offline) - except OSError as error: - print(f"Portable verification failed: {error}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crates/doc-harness/examples/configured.rs b/crates/doc-harness/examples/configured.rs deleted file mode 100644 index ae13898..0000000 --- a/crates/doc-harness/examples/configured.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! The host owns its project root, fixtures, audience destinations and export layout. -//! Run: configured PROJECT_ROOT BASELINE_ROOT check|update|build [SDK options] -//! PROJECT_ROOT contains fixtures/ports.txt with two nonzero ports, one per line. -use executable_docs::{ - Audience, Doc, Document, ExportLayout, Result, prose, - runner::{RunnerConfig, run_cli_with}, -}; -use std::path::Path; - -fn main() { - if let Err(error) = execute() { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn execute() -> Result<()> { - let mut args = std::env::args().skip(1); - let usage = "Usage: configured PROJECT_ROOT BASELINE_ROOT check|update|build [SDK options]"; - let project = args.next().ok_or(usage)?; - let baseline = args.next().ok_or(usage)?; - let project = Path::new(&project) - .canonicalize() - .map_err(|error| format!("Cannot resolve the project root: {error}"))?; - let config = configuration(&project, Path::new(&baseline))?; - run_cli_with(args, &config, || guide(&project)) -} - -fn configuration(project: &Path, baseline: &Path) -> Result { - let layout = ExportLayout::default() - .pages_under("handbook")? - .resources_under("downloads/evidence")? - .page("ports", "manual/network/index.md")?; - Ok(RunnerConfig::new(project)? - .output(Audience::Reader, baseline.join("published"), "website")? - .output( - Audience::Contributor, - baseline.join("review-notes"), - "maintainers", - )? - .layout(layout)) -} - -fn guide(project: &Path) -> Result> { - let text = std::fs::read_to_string(project.join("fixtures/ports.txt")) - .map_err(|error| format!("Cannot read the project's ports fixture: {error}"))?; - let ports: Vec = text - .lines() - .map(|line| line.parse::().map_err(|error| error.to_string())) - .collect::>()?; - let mut doc = Doc::new("ports", "Read configured ports")?; - let count = doc.expect_eq("fixture-port-count", ports.len(), 2)?; - doc.require("ports-are-nonzero", ports.iter().all(|port| *port != 0))?; - doc.paragraph(prose!( - "The fixture supplies {count} nonzero ports.", - count = &count - )?)?; - let input = doc.text_code("input", text, "text")?; - doc.paragraph(("Download ", &input, " to reuse the fixture."))?; - let observation = doc.json("private-observation", &ports)?; - doc.note_on( - &input, - ( - "The private parsed-port observation is ", - &observation, - ". It is included only in contributor exports.", - ), - )?; - - let mut overview = Doc::new("overview", "Fixture contract")?; - overview.require("fixture-was-parsed", !ports.is_empty())?; - overview.paragraph("This project reads its own fixture before composing the documents.")?; - Ok(vec![doc.finish()?, overview.finish()?]) -} - -#[cfg(test)] -mod tests { - use super::*; - use executable_docs::{lifecycle, runner::RunMode, runner::run}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[test] - fn project_layout_and_audience_destinations_are_host_owned() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let project = root.join("source-only-project"); - let baseline = root.join("external-publisher/retained"); - std::fs::create_dir_all(project.join("fixtures")).unwrap(); - std::fs::create_dir_all(&baseline).unwrap(); - std::fs::write(project.join("fixtures/ports.txt"), "8080\n9090\n").unwrap(); - std::fs::write(baseline.join("publisher-owned.txt"), "Keep this file.\n").unwrap(); - let config = configuration(&project, &baseline).unwrap(); - let audiences = [Audience::Reader, Audience::Contributor]; - let executions = AtomicUsize::new(0); - let generate = || { - executions.fetch_add(1, Ordering::SeqCst); - guide(&project) - }; - - run(&config, RunMode::Update, &audiences, generate).unwrap(); - assert_eq!(executions.load(Ordering::SeqCst), 1); - run(&config, RunMode::Check, &audiences, generate).unwrap(); - let candidate = root.join("separate-review-output"); - run( - &config, - RunMode::Build(candidate.clone()), - &audiences, - generate, - ) - .unwrap(); - assert_eq!(executions.load(Ordering::SeqCst), 3); - - let reader = lifecycle::read_tree(&candidate.join("website")).unwrap(); - let contributor = lifecycle::read_tree(&candidate.join("maintainers")).unwrap(); - assert!(reader.contains_key("manual/network/index.md")); - assert!(reader.contains_key("handbook/overview.md")); - assert!(reader.contains_key("downloads/evidence/ports/input.txt")); - assert!( - !reader - .keys() - .any(|path| path.contains("private-observation")) - ); - assert!( - contributor - .keys() - .any(|path| path.contains("private-observation")) - ); - for (name, files) in [("published", reader), ("review-notes", contributor)] { - assert_eq!(files, lifecycle::read_tree(&baseline.join(name)).unwrap()); - for bytes in files.values() { - let text = String::from_utf8_lossy(bytes); - assert!(!text.contains(root.to_str().unwrap())); - } - } - assert_eq!( - std::fs::read_to_string(baseline.join("publisher-owned.txt")).unwrap(), - "Keep this file.\n" - ); - assert!(!project.join("Cargo.toml").exists()); - assert!(!project.join(".git").exists()); - assert!(!candidate.join("reader").exists()); - assert!(!candidate.join("contributor").exists()); - } -} diff --git a/crates/doc-harness/examples/preview.rs b/crates/doc-harness/examples/preview.rs deleted file mode 100644 index 992550c..0000000 --- a/crates/doc-harness/examples/preview.rs +++ /dev/null @@ -1,488 +0,0 @@ -//! Disposable static rendering for human review, separate from guide generation. -use executable_docs::{ - Result, - lifecycle::{self, Files, Ownership}, -}; -use pulldown_cmark::{Event, Options, Parser, Tag, html}; -use std::{ - collections::{BTreeMap, BTreeSet}, - env, - path::Path, -}; - -fn main() -> std::result::Result<(), Box> { - let args: Vec<_> = env::args().skip(1).collect(); - if args.len() != 2 { - return Err("Usage: preview EXPORT_DIRECTORY NEW_PREVIEW_DIRECTORY".into()); - } - let destination = Path::new(&args[1]); - preview(Path::new(&args[0]), destination)?; - println!("Preview: {}", destination.display()); - Ok(()) -} - -fn preview(source: &Path, destination: &Path) -> Result<()> { - let source = source.canonicalize().map_err(|error| error.to_string())?; - let destination = lifecycle::absolute(destination)?; - lifecycle::ensure_separate_paths(&source, &destination)?; - if !source.is_dir() { - return Err("Preview source must be an exported directory".into()); - } - if destination.exists() { - return Err("Preview destination must be new; preserve earlier review evidence".into()); - } - let mut files = lifecycle::read_tree(&source)?; - files.remove(".ownership.json"); - // Validate the complete tree before rendering. In particular, a private or - // unrecorded file added to a Reader export must never become a preview asset. - executable_docs::store::check(&source, &files)?; - let output = preview_files(&files)?; - lifecycle::build(&destination, &output, Ownership::Dedicated) -} - -fn preview_link(destination: &str, page: &str, pages: &BTreeMap) -> Result { - if destination.contains(':') || destination.starts_with("//") { - return Ok(destination.to_owned()); - } - let (path, fragment) = destination - .split_once('#') - .map_or((destination, None), |(path, fragment)| { - (path, Some(fragment)) - }); - if path.is_empty() { - return Ok(destination.to_owned()); - } - let decoded = decode_path(path)?; - let mut target: Vec<_> = page.split('/').collect(); - target.pop(); - for part in decoded.split('/') { - match part { - "." => {} - ".." => { - target - .pop() - .ok_or_else(|| format!("Preview link escapes export: {destination}"))?; - } - _ => target.push(part), - } - } - let Some(output) = pages.get(&target.join("/")) else { - return Ok(destination.to_owned()); - }; - let mut directory: Vec<_> = pages[page].split('/').collect(); - directory.pop(); - let output: Vec<_> = output.split('/').collect(); - let common = directory - .iter() - .zip(&output) - .take_while(|(left, right)| left == right) - .count(); - let relative = std::iter::repeat_n("..", directory.len() - common) - .chain(output[common..].iter().copied()) - .collect::>() - .join("/"); - let mut link: String = relative - .bytes() - .map(|byte| { - if byte.is_ascii_alphanumeric() || b"/-._~".contains(&byte) { - (byte as char).to_string() - } else { - format!("%{byte:02X}") - } - }) - .collect(); - if let Some(fragment) = fragment { - link.push('#'); - link.push_str(fragment); - } - Ok(link) -} - -fn decode_path(path: &str) -> Result { - let mut bytes = Vec::new(); - let mut position = 0; - while position < path.len() { - if path.as_bytes()[position] == b'%' { - let escape = path - .get(position + 1..position + 3) - .ok_or_else(|| format!("Malformed preview link escape: {path}"))?; - bytes.push( - u8::from_str_radix(escape, 16) - .map_err(|_| format!("Malformed preview link escape: {path}"))?, - ); - position += 3; - } else { - bytes.push(path.as_bytes()[position]); - position += 1; - } - } - String::from_utf8(bytes).map_err(|_| format!("Preview link is not UTF-8: {path}")) -} - -fn preview_files(files: &Files) -> Result { - let manifest: serde_json::Value = - serde_json::from_slice(&files["manifest.json"]).map_err(|error| error.to_string())?; - let artifacts = manifest["artifacts"] - .as_array() - .ok_or("Manifest artifacts must be an array")?; - // The store already owns schema validation. This review tool only needs the - // inventory's page identities; extensions say nothing about artifact kind. - let pages: BTreeMap<_, _> = artifacts - .iter() - .filter(|artifact| artifact["kind"] == "page") - .map(|artifact| { - let path = artifact["path"] - .as_str() - .ok_or("Manifest artifact requires a path")?; - let output = Path::new(path).with_extension("html"); - Ok(( - path.to_owned(), - output.to_str().ok_or("Non UTF-8 preview path")?.to_owned(), - )) - }) - .collect::>()?; - let mut paths = BTreeSet::new(); - for path in files.keys().filter(|path| path.as_str() != "manifest.json") { - let output = pages.get(path).unwrap_or(path); - if paths.iter().any(|existing: &String| { - existing == output - || output.starts_with(&format!("{existing}/")) - || existing.starts_with(&format!("{output}/")) - }) { - return Err(format!("Preview output collision: {output}")); - } - paths.insert(output.to_owned()); - } - let mut output = Files::new(); - for (path, bytes) in files - .iter() - .filter(|(path, _)| path.as_str() != "manifest.json") - { - let Some(output_path) = pages.get(path) else { - output.insert(path.clone(), bytes.clone()); - continue; - }; - let source = - std::str::from_utf8(bytes).map_err(|error| format!("Preview page {path}: {error}"))?; - let parser = Parser::new_ext( - source, - Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH, - ) - .map(|event| match event { - Event::Start(Tag::Link { - link_type, - dest_url, - title, - id, - }) => Ok(Event::Start(Tag::Link { - link_type, - dest_url: preview_link(&dest_url, path, &pages)?.into(), - title, - id, - })), - other => Ok(other), - }) - .collect::>>()?; - let mut body = String::new(); - html::push_html(&mut body, parser.into_iter()); - output.insert(output_path.clone(), format!( - "Executable documentation review
{body}
" - ).into_bytes()); - } - Ok(output) -} - -const STYLE: &str = r#" -html { color-scheme: light; background: #f5f6f8; color: #17212b; } -body { margin: 0; font: 17px/1.65 -apple-system, BlinkMacSystemFont, sans-serif; } -main { max-width: 800px; margin: 42px auto; padding: 36px 48px; background: white; border: 1px solid #dde2e7; border-radius: 12px; } -h1, h2, h3 { line-height: 1.2; color: #0b243c; } h1 { font-size: 34px; } h2 { margin-top: 38px; } -a { color: #005d9f; } code { font: 0.88em/1.5 ui-monospace, monospace; background: #f1f3f6; padding: 2px 5px; border-radius: 3px; } -pre { overflow: auto; padding: 18px; border: 1px solid #dce3e9; border-radius: 6px; background: #f7f9fb; } pre code { padding: 0; background: transparent; } -blockquote { margin: 24px 0; border-left: 4px solid #ae7c19; background: #fff8e6; padding: 8px 20px; } -img { max-width: 100%; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; border: 1px solid #dce3e9; padding: 8px; } -@media(max-width: 700px) { main { margin: 0; padding: 22px; border: 0; } } -"#; - -#[cfg(test)] -mod tests { - use super::*; - use executable_docs::{Artifact, Audience, Doc, Document, ExportLayout}; - use std::fs; - - fn doc(id: &str) -> Doc { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - doc - } - - fn text(bytes: &[u8], extension: &str) -> Artifact { - Artifact::new(bytes, "text/plain", extension, "preview-test", "v1").unwrap() - } - - fn export(source: &Path, document: Document) { - let files = document - .render_with(Audience::Reader, &ExportLayout::default()) - .unwrap(); - executable_docs::store::build(source, &files).unwrap(); - } - - #[test] - fn preview_rejects_overlapping_trees_before_creating_output() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - fs::create_dir(&source).unwrap(); - fs::write(source.join("guide.md"), "Retain the source.").unwrap(); - for destination in [&source, &source.join("preview"), &root] { - assert!(preview(&source, destination).is_err()); - } - assert_eq!(fs::read_dir(&source).unwrap().count(), 1); - assert_eq!( - fs::read_to_string(source.join("guide.md")).unwrap(), - "Retain the source." - ); - } - - #[test] - fn preview_uses_inventory_for_pages_downloads_and_both_audiences() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let mut first = doc("start"); - first.heading_with_id("intro", 2, "Start here").unwrap(); - first - .markdown( - "~~Old wording~~\n\n| Option | Value |\n| --- | --- |\n| Retries | 3 |\n\n\ - [Other](nested/other.source#target)\n[Self](#intro)\n\ - [Remote](https://example.com/other.md#section)\n\ - [Email](mailto:editor@example.md)", - ) - .unwrap(); - let markdown_download = - b"# Download remains Markdown\n[Other](../manual/nested/other.source#target)\n"; - let download = first - .resource_at( - "download", - text(markdown_download, "md"), - "downloads/raw.md", - ) - .unwrap(); - first.paragraph(download.link("Download source")).unwrap(); - let hidden = first - .resource_at( - "hidden", - text(b"A documented hidden file", "txt"), - ".fixture.txt", - ) - .unwrap(); - first.paragraph(hidden.link("Hidden filename")).unwrap(); - let private = first - .resource_at( - "private", - text(b"Private context", "txt"), - "internal/session.txt", - ) - .unwrap(); - first - .note(("Private rationale: ", private.link("Session"))) - .unwrap(); - let mut other = doc("other"); - other.heading_with_id("target", 2, "Other").unwrap(); - other - .markdown("[Back](../start.guide#intro)\n[Download](../../downloads/raw.md)") - .unwrap(); - let documents = [first.finish().unwrap(), other.finish().unwrap()]; - let layout = ExportLayout::default() - .page("start", "manual/start.guide") - .unwrap() - .page("other", "manual/nested/other.source") - .unwrap(); - for (audience, name) in [ - (Audience::Reader, "reader"), - (Audience::Contributor, "contributor"), - ] { - let source = root.join(format!("{name}-export")); - let destination = root.join(format!("{name}-preview")); - let files = executable_docs::render_with(&documents, audience, &layout).unwrap(); - executable_docs::store::build(&source, &files).unwrap(); - let retained = lifecycle::read_tree(&source).unwrap(); - preview(&source, &destination).unwrap(); - let html = fs::read_to_string(destination.join("manual/start.html")).unwrap(); - for target in [ - "nested/other.html#target", - "#intro", - "../downloads/raw.md", - "https://example.com/other.md#section", - "mailto:editor@example.md", - ] { - assert!(html.contains(&format!("href=\"{target}\"")), "{html}"); - } - assert!(html.contains("Old wording"), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - let nested = fs::read_to_string(destination.join("manual/nested/other.html")).unwrap(); - assert!(nested.contains("href=\"../start.html#intro\""), "{nested}"); - assert!( - nested.contains("href=\"../../downloads/raw.md\""), - "{nested}" - ); - assert_eq!( - fs::read(destination.join("downloads/raw.md")).unwrap(), - markdown_download - ); - assert!(!destination.join("downloads/raw.html").exists()); - assert_eq!( - fs::read(destination.join(".fixture.txt")).unwrap(), - b"A documented hidden file" - ); - assert_eq!( - html.contains("Private rationale"), - audience == Audience::Contributor - ); - assert_eq!( - destination.join("internal/session.txt").exists(), - audience == Audience::Contributor - ); - assert!(!destination.join(".ownership.json").exists()); - assert!(!destination.join("manifest.json").exists()); - assert!(!destination.join("manual/start.guide").exists()); - assert_eq!(lifecycle::read_tree(&source).unwrap(), retained); - } - } - - #[test] - fn preview_preserves_external_urls_and_resolves_encoded_page_names() { - let pages = BTreeMap::from([ - ("manual/start.guide".into(), "manual/start.html".into()), - ( - "manual/入口/other.source".into(), - "manual/入口/other.html".into(), - ), - ]); - let page = "manual/start.guide"; - assert_eq!( - preview_link("%E5%85%A5%E5%8F%A3/other.source#target", page, &pages).unwrap(), - "%E5%85%A5%E5%8F%A3/other.html#target" - ); - for link in [ - "https://example.com/other.md#section", - "mailto:editor@example.md", - "//example.com/other.md", - ] { - assert_eq!(preview_link(link, page, &pages).unwrap(), link); - } - } - - #[test] - fn preview_rejects_page_resource_and_directory_collisions_before_publication() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - for (index, path) in ["guide.html", "guide.html/download.txt"] - .into_iter() - .enumerate() - { - let source = root.join(format!("export-{index}")); - let destination = root.join(format!("preview-{index}")); - let mut guide = doc("guide"); - let resource = guide - .resource_at("collision", text(b"Retained bytes", "txt"), path) - .unwrap(); - guide.paragraph(resource.link("Download")).unwrap(); - export(&source, guide.finish().unwrap()); - let error = preview(&source, &destination).unwrap_err(); - assert!(error.contains("Preview output collision"), "{error}"); - assert!(!destination.exists()); - assert_eq!(fs::read(source.join(path)).unwrap(), b"Retained bytes"); - } - } - - #[test] - fn preview_rejects_pages_that_map_to_the_same_html_path() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - let destination = root.join("preview"); - let documents = [ - doc("first").finish().unwrap(), - doc("second").finish().unwrap(), - ]; - let layout = ExportLayout::default() - .page("first", "guide.md") - .unwrap() - .page("second", "guide.source") - .unwrap(); - let files = executable_docs::render_with(&documents, Audience::Reader, &layout).unwrap(); - executable_docs::store::build(&source, &files).unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("Preview output collision") - ); - assert!(!destination.exists()); - } - - #[test] - fn preview_rejects_changed_or_unrecorded_source_files_and_preserves_existing_review() { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - let destination = root.join("preview"); - let mut guide = doc("guide"); - guide.paragraph("Retain the source.").unwrap(); - export(&source, guide.finish().unwrap()); - fs::write(source.join("private.txt"), "Must never leak").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("unrecorded") - ); - assert!(!destination.exists()); - fs::remove_file(source.join("private.txt")).unwrap(); - fs::write(source.join("guide.md"), "Changed after export").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!(!destination.exists()); - fs::create_dir(&destination).unwrap(); - fs::write(destination.join("review.txt"), "Earlier evidence").unwrap(); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("must be new") - ); - assert_eq!( - fs::read(destination.join("review.txt")).unwrap(), - b"Earlier evidence" - ); - } - - #[cfg(unix)] - #[test] - fn preview_rejects_symlinks_in_export_and_destination_components() { - use std::os::unix::fs::symlink; - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let source = root.join("export"); - export(&source, doc("guide").finish().unwrap()); - symlink(source.join("guide.md"), source.join("linked.md")).unwrap(); - let destination = root.join("preview"); - assert!( - preview(&source, &destination) - .unwrap_err() - .contains("symlink") - ); - assert!(!destination.exists()); - fs::remove_file(source.join("linked.md")).unwrap(); - let alias = root.join("alias"); - symlink(&root, &alias).unwrap(); - assert!( - preview(&source, &alias.join("preview")) - .unwrap_err() - .to_lowercase() - .contains("symlink") - ); - assert!(!destination.exists()); - } -} diff --git a/crates/doc-harness/src/artifact.rs b/crates/doc-harness/src/artifact.rs deleted file mode 100644 index f5bbb16..0000000 --- a/crates/doc-harness/src/artifact.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Owned artifact payloads and the explicit file-reading boundary. - -use crate::Result; -use std::path::Path; - -/// Owned bytes and producer metadata. Files are read immediately, never at render time. -#[derive(Clone, Debug)] -pub struct Artifact { - pub(crate) bytes: Vec, - pub(crate) mime: String, - pub(crate) extension: String, - pub(crate) producer: String, - pub(crate) profile: String, -} - -impl Artifact { - pub fn new( - bytes: impl Into>, - mime: &str, - extension: &str, - producer: &str, - profile: &str, - ) -> Result { - let (major, minor) = mime - .split_once('/') - .ok_or("MIME type requires type/subtype")?; - let token = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&b)) - }; - if !token(major) || !token(minor) { - return Err("MIME type must be a concrete type/subtype without parameters".into()); - } - if extension.is_empty() - || !extension - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) - { - return Err( - "Artifact extension must contain lowercase letters/digits without a dot".into(), - ); - } - for (label, value) in [("producer", producer), ("profile", profile)] { - if value.is_empty() || value.chars().any(char::is_control) { - return Err(format!( - "Artifact {label} must be nonempty and contain no control characters" - )); - } - } - Ok(Self { - bytes: bytes.into(), - mime: mime.into(), - extension: extension.into(), - producer: producer.into(), - profile: profile.into(), - }) - } - - pub fn from_file( - path: impl AsRef, - mime: &str, - extension: &str, - producer: &str, - profile: &str, - ) -> Result { - let bytes = std::fs::read(path.as_ref()) - .map_err(|e| format!("Cannot freeze artifact {}: {e}", path.as_ref().display()))?; - Self::new(bytes, mime, extension, producer, profile) - } -} diff --git a/crates/doc-harness/src/authoring.rs b/crates/doc-harness/src/authoring.rs deleted file mode 100644 index 657a27f..0000000 --- a/crates/doc-harness/src/authoring.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! Rust authoring operations and evidence acquisition before document freezing. - -use crate::{ - Artifact, Result, - model::{ - Binding, BindingValue, Block, Check, Checked, Claim, Content, Document, Inline, IntoProse, - Kind, Node, Reference, Resource, Target, valid_id, - }, -}; -use serde::Serialize; -use serde_json::Value; -use std::{ - collections::BTreeMap, - marker::PhantomData, - sync::atomic::{AtomicU64, Ordering}, -}; - -static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); - -pub struct Doc { - owner: u64, - ids: BTreeMap, - document: Document, - errors: Vec, -} - -impl Doc { - pub fn new(id: &str, title: &str) -> Result { - if !valid_id(id) { - return Err(format!("Invalid document ID: {id:?}")); - } - if title.trim().is_empty() || title.chars().any(char::is_control) { - return Err("Document title must be nonempty single-line text".into()); - } - Ok(Self { - owner: NEXT_OWNER.fetch_add(1, Ordering::Relaxed), - ids: BTreeMap::new(), - document: Document { - id: id.into(), - title: title.into(), - nodes: Vec::new(), - checks: BTreeMap::new(), - artifacts: BTreeMap::new(), - resource_paths: BTreeMap::new(), - bindings: BTreeMap::new(), - }, - errors: Vec::new(), - }) - } - - fn guarded(&mut self, result: Result) -> Result { - result.map_err(|error| { - let contextual = format!("Document {}: {error}", self.document.id); - self.errors.push(contextual.clone()); - contextual - }) - } - - fn reserve(&mut self, id: &str, kind: Kind) -> Result { - if !valid_id(id) { - return Err(format!("Invalid evidence ID: {id:?}")); - } - if self.ids.contains_key(id) { - return Err(format!("Duplicate evidence ID: {id}")); - } - self.ids.insert(id.into(), kind.clone()); - Ok(Reference { - owner: self.owner, - id: id.into(), - kind, - }) - } - - fn validate_reference(&self, reference: &Reference) -> Result<()> { - if reference.owner != self.owner { - return Err(format!("Foreign document/run handle: {}", reference.id)); - } - if self.ids.get(&reference.id) != Some(&reference.kind) { - return Err(format!("Missing or wrong-kind reference: {}", reference.id)); - } - Ok(()) - } - - fn append(&mut self, content: Content) -> Result { - self.append_with_id(None, content) - } - - fn append_with_id(&mut self, id: Option<&str>, content: Content) -> Result { - let result = (|| { - let note = matches!(content, Content::Note { .. }); - let count = self - .document - .nodes - .iter() - .filter(|node| node.is_note() == note) - .count(); - let prefix = if note { "note" } else { "block" }; - let node = Node { - id: id - .map(str::to_owned) - .unwrap_or_else(|| format!("{prefix}-{:04}", count + 1)), - content, - }; - for reference in node.references() { - self.validate_reference(reference)?; - } - if let Content::Paragraph(prose) - | Content::MarkdownParts(prose) - | Content::Note { prose, .. } = &node.content - { - for part in &prose.0 { - if let Inline::Image(reference, _) = part - && !self.document.artifacts[&reference.id] - .mime - .starts_with("image/") - { - return Err(format!( - "Resource {} is not declared as an image", - reference.id - )); - } - } - } - let reference = self.reserve(&node.id, Kind::Block)?; - self.document.nodes.push(node); - Ok(Block(reference)) - })(); - self.guarded(result) - } - - pub fn heading(&mut self, level: u8, text: &str) -> Result { - self.append_heading(None, level, text) - } - - /// Give a section an explicit anchor that survives inserting other blocks. - /// IDs share the document's evidence namespace; duplicates fail. Other - /// headings retain their positional IDs unless explicitly named here. - pub fn heading_with_id(&mut self, id: &str, level: u8, text: &str) -> Result { - self.append_heading(Some(id), level, text) - } - - fn append_heading(&mut self, id: Option<&str>, level: u8, text: &str) -> Result { - if !(1..=6).contains(&level) || text.trim().is_empty() || text.chars().any(char::is_control) - { - return self.guarded(Err( - "Heading requires level 1..6 and nonempty single-line text".into(), - )); - } - self.append_with_id(id, Content::Heading(level, text.into())) - } - - pub fn paragraph(&mut self, prose: impl IntoProse) -> Result { - self.append(Content::Paragraph(prose.into_prose())) - } - - /// CommonMark plus tables and strikethrough. Reference links and code are parsed - /// by pulldown-cmark; unresolved references and raw HTML fail explicitly. - /// Local links target emitted files or explicit block anchors. External - /// http(s)/mailto links are not network-verified. - pub fn markdown(&mut self, text: &str) -> Result { - let parsed = crate::markdown::markdown_links(text); - if let Err(error) = parsed { - return self.guarded(Err(error)); - } - self.append(Content::Markdown(text.into())) - } - - /// Preserve literal Markdown bytes while inserting typed values, bindings and - /// resource views. No separator is appended. Literal HTML is still rejected; - /// only typed Code/Keys bindings can generate the corresponding safe HTML. - pub fn markdown_parts(&mut self, parts: impl IntoProse) -> Result { - let parts = parts.into_prose(); - let validation = crate::markdown::validate_markdown_parts(&parts); - self.guarded(validation)?; - self.append(Content::MarkdownParts(parts)) - } - - pub fn binding(&mut self, id: &str, value: BindingValue) -> Result { - let result = (|| { - match &value { - BindingValue::Text(text) | BindingValue::Code(text) => { - if text.is_empty() || text.chars().any(char::is_control) { - return Err("Binding labels must be nonempty single-line text".into()); - } - } - BindingValue::Keys(keys) => { - if keys.is_empty() - || keys - .iter() - .any(|text| text.is_empty() || text.chars().any(char::is_control)) - { - return Err("Key bindings require nonempty single-line key labels".into()); - } - } - } - let reference = self.reserve(id, Kind::Binding)?; - self.document.bindings.insert(id.into(), value); - Ok(Binding(reference)) - })(); - self.guarded(result) - } - - pub fn note(&mut self, prose: impl IntoProse) -> Result { - self.append(Content::Note { - target: None, - prose: prose.into_prose(), - }) - } - - pub fn note_on(&mut self, target: &impl Target, prose: impl IntoProse) -> Result { - self.append(Content::Note { - target: Some(target.reference()), - prose: prose.into_prose(), - }) - } - - pub fn expect_eq( - &mut self, - id: &str, - actual: T, - expected: T, - ) -> Result> { - let result = (|| { - let reference = self.reserve(id, Kind::Value)?; - let mut actual_json = serde_json::to_value(&actual) - .map_err(|e| format!("Cannot freeze observed value {id}: {e}"))?; - let mut expected_json = serde_json::to_value(&expected) - .map_err(|e| format!("Cannot freeze expected value {id}: {e}"))?; - // Cargo can unify serde_json's preserve_order feature through the - // host. Freeze all object keys in canonical order in either mode. - actual_json.sort_all_objects(); - expected_json.sort_all_objects(); - if actual != expected { - return Err(format!( - "Check {id} failed: observed {actual_json}, expected {expected_json}" - )); - } - self.document.checks.insert( - id.into(), - Check { - id: id.into(), - kind: "equality".into(), - actual: actual_json.clone(), - expected: expected_json, - }, - ); - Ok(Checked { - reference, - observed: actual_json, - marker: PhantomData, - }) - })(); - self.guarded(result) - } - - pub fn require(&mut self, id: &str, condition: bool) -> Result { - let result = (|| { - let reference = self.reserve(id, Kind::Claim)?; - if !condition { - return Err(format!("Claim {id} failed")); - } - self.document.checks.insert( - id.into(), - Check { - id: id.into(), - kind: "predicate".into(), - actual: Value::Bool(true), - expected: Value::Bool(true), - }, - ); - Ok(Claim(reference)) - })(); - self.guarded(result) - } - - pub fn resource(&mut self, id: &str, artifact: Artifact) -> Result { - let result = (|| { - let reference = self.reserve(id, Kind::Resource)?; - self.document.artifacts.insert(id.into(), artifact); - Ok(Resource(reference)) - })(); - self.guarded(result) - } - - /// Register a resource at a safe host-owned relative path. The complete - /// renderer also rejects collisions across documents and generated pages. - pub fn resource_at(&mut self, id: &str, artifact: Artifact, path: &str) -> Result { - let result = (|| { - crate::paths::validate_resource_path(path)?; - if self - .document - .resource_paths - .values() - .any(|existing| existing == path) - { - return Err(format!("Duplicate artifact path: {path}")); - } - let reference = self.reserve(id, Kind::Resource)?; - self.document.resource_paths.insert(id.into(), path.into()); - self.document.artifacts.insert(id.into(), artifact); - Ok(Resource(reference)) - })(); - self.guarded(result) - } - - /// Register and place a text resource without replacing its producer metadata. - /// The returned handle can be referenced or annotated again without executing - /// the producer or copying the artifact into the document a second time. - pub fn resource_code( - &mut self, - id: &str, - artifact: Artifact, - language: &str, - ) -> Result { - let resource = self.resource(id, artifact)?; - self.code(&resource, language)?; - Ok(resource) - } - - /// Freeze UTF-8 text with the built-in text producer. - pub fn text(&mut self, id: &str, text: impl Into) -> Result { - let artifact = Artifact::new( - text.into().into_bytes(), - "text/plain", - "txt", - "executable-docs/text", - "utf8-v1", - ); - let artifact = self.guarded(artifact)?; - self.resource(id, artifact) - } - - /// Freeze a JSON value with the built-in deterministic JSON producer. - pub fn json(&mut self, id: &str, value: &impl Serialize) -> Result { - let result = (|| { - // Value alone is not sorted when a host enables preserve_order. - // Sort recursively, including objects inside arrays, before freezing. - let mut value = serde_json::to_value(value).map_err(|e| e.to_string())?; - value.sort_all_objects(); - let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - Artifact::new( - bytes, - "application/json", - "json", - "executable-docs/json", - "json-pretty-v1", - ) - })(); - let artifact = self.guarded(result)?; - self.resource(id, artifact) - } - - pub fn text_code( - &mut self, - id: &str, - text: impl Into, - language: &str, - ) -> Result { - let resource = self.text(id, text)?; - self.code(&resource, language)?; - Ok(resource) - } - - pub fn json_code(&mut self, id: &str, value: &impl Serialize) -> Result { - let resource = self.json(id, value)?; - self.code(&resource, "json")?; - Ok(resource) - } - - pub fn embed(&mut self, resource: &Resource, caption: &str) -> Result { - self.append(Content::Embed(resource.reference(), caption.into())) - } - - pub fn code(&mut self, resource: &Resource, language: &str) -> Result { - let result = (|| { - self.validate_reference(&resource.0)?; - if !language - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) - { - return Err("Code language must be an ASCII identifier".into()); - } - let artifact = &self.document.artifacts[resource.id()]; - if !(artifact.mime.starts_with("text/") || artifact.mime == "application/json") { - return Err(format!( - "Resource {} is not declared as text/JSON", - resource.id() - )); - } - std::str::from_utf8(&artifact.bytes) - .map_err(|_| format!("Resource {} is not UTF-8", resource.id()))?; - Ok(()) - })(); - self.guarded(result)?; - self.append(Content::Code(resource.reference(), language.into())) - } - - /// Freeze successful execution and intrinsic references. Export validates - /// layout-dependent resource ownership and local links against its complete - /// bundle; finishing alone does not mean the document is publishable. - pub fn finish(self) -> Result { - if !self.errors.is_empty() { - return Err(format!( - "Document {} is tainted by failed operations: {}", - self.document.id, - self.errors.join("; ") - )); - } - if self.document.checks.is_empty() { - return Err(format!( - "Document {} has no executed checks", - self.document.id - )); - } - // Publication paths and cross-document links depend on the selected export layout. - crate::validation::validate_document(&self.document)?; - Ok(self.document) - } -} diff --git a/crates/doc-harness/src/layout.rs b/crates/doc-harness/src/layout.rs deleted file mode 100644 index 2907c27..0000000 --- a/crates/doc-harness/src/layout.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::model::{Document, valid_id}; -use crate::{Result, paths::validate_resource_path}; -use std::collections::BTreeMap; - -/// Bundle-relative Markdown paths, independent of a repository or build system. -/// -/// Defaults preserve the ordinary `.md` and -/// `assets//.` layout. Explicit -/// [`crate::Doc::resource_at`] paths remain relative to the bundle root and are -/// never prefixed or rewritten. `manifest.json` and `.ownership.json` (including -/// their subtrees) are reserved at the bundle root by the export protocol. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExportLayout { - pages: String, - resources: String, - page_overrides: BTreeMap, -} - -impl Default for ExportLayout { - fn default() -> Self { - Self { - pages: String::new(), - resources: "assets".into(), - page_overrides: BTreeMap::new(), - } - } -} - -impl ExportLayout { - /// Place default document pages under this bundle-relative directory. - /// An empty string selects the bundle root. Overrides set by [`Self::page`] - /// remain exact paths, independent of this prefix. - pub fn pages_under(mut self, path: &str) -> Result { - validate_directory(path)?; - self.pages = path.into(); - Ok(self) - } - - /// Place automatically named resources under this bundle-relative directory. - /// An empty string selects the bundle root. Explicit resource paths are unchanged. - pub fn resources_under(mut self, path: &str) -> Result { - validate_directory(path)?; - self.resources = path.into(); - Ok(self) - } - - /// Set one document's exact bundle-relative page path. Duplicate assignments - /// are errors, including identical assignments. Rendering rejects overrides - /// for document IDs absent from the supplied bundle. - pub fn page(mut self, document_id: &str, path: &str) -> Result { - if !valid_id(document_id) { - return Err(format!( - "Invalid document ID in export layout: {document_id:?}" - )); - } - validate_resource_path(path)?; - if self.page_overrides.contains_key(document_id) { - return Err(format!( - "Duplicate page override for document: {document_id}" - )); - } - self.page_overrides.insert(document_id.into(), path.into()); - Ok(self) - } - - /// Resolve a document's page path without accessing a filesystem. - pub fn page_path(&self, document_id: &str) -> String { - self.page_overrides - .get(document_id) - .cloned() - .unwrap_or_else(|| under(&self.pages, &format!("{document_id}.md"))) - } - - pub(crate) fn resource_path(&self, document: &Document, id: &str) -> String { - document.resource_paths.get(id).cloned().unwrap_or_else(|| { - under( - &self.resources, - &format!( - "{}/{}.{}", - document.id, id, document.artifacts[id].extension - ), - ) - }) - } - - pub(crate) fn validate_documents(&self, documents: &[Document]) -> Result<()> { - for id in self.page_overrides.keys() { - if !documents.iter().any(|document| &document.id == id) { - return Err(format!("Page override names an unknown document: {id}")); - } - } - Ok(()) - } -} - -fn validate_directory(path: &str) -> Result<()> { - if path.is_empty() { - Ok(()) - } else { - validate_resource_path(path) - } -} - -fn under(directory: &str, path: &str) -> String { - if directory.is_empty() { - path.into() - } else { - format!("{directory}/{path}") - } -} diff --git a/crates/doc-harness/src/lib.rs b/crates/doc-harness/src/lib.rs deleted file mode 100644 index 915532b..0000000 --- a/crates/doc-harness/src/lib.rs +++ /dev/null @@ -1,45 +0,0 @@ -#![doc = include_str!("../README.md")] - -mod artifact; -mod authoring; -mod layout; -pub mod lifecycle; -mod manifest; -mod markdown; -mod model; -mod paths; -mod prose; -mod render; -pub mod runner; -pub mod store; -mod validation; - -pub use artifact::Artifact; -pub use authoring::Doc; -pub use layout::ExportLayout; -pub use model::{ - Audience, Binding, BindingValue, Block, Checked, Claim, Document, IntoProse, Prose, Resource, - Target, -}; -pub use render::{render, render_with}; - -pub type Result = std::result::Result; - -/// Combine text and typed handles without losing evidence references. -/// The expression-list form returns a sequence; named interpolation returns -/// `Result` and rejects missing, duplicate, or unused bindings. -/// Each supplied expression is evaluated once, in order, through [`IntoProse`]. -#[macro_export] -macro_rules! prose { - ($text:literal, $($name:ident = $value:expr),+ $(,)?) => { - $crate::Prose::interpolate( - $text, - [$( (::std::stringify!($name), $crate::IntoProse::into_prose($value)) ),+], - ) - }; - ($($part:expr),* $(,)?) => {{ - let parts: ::std::vec::Vec<$crate::Prose> = - ::std::vec![$($crate::IntoProse::into_prose($part)),*]; - parts - }}; -} diff --git a/crates/doc-harness/src/lifecycle.rs b/crates/doc-harness/src/lifecycle.rs deleted file mode 100644 index e43ce3d..0000000 --- a/crates/doc-harness/src/lifecycle.rs +++ /dev/null @@ -1,686 +0,0 @@ -//! Shared exact artifact collection and filesystem publication. -//! -//! Dedicated trees retain their existing file format. Recorded trees add an -//! ownership inventory; neither policy changes application-specific manifests. -//! -//! Each destination publishes independently. Renames and rollback protect against -//! ordinary errors, but this is not a crash-atomic filesystem transaction. An -//! interrupted transaction leaves named recovery material and fails closed. -use crate::Result; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{BTreeMap, BTreeSet}, - fs::{self, OpenOptions}, - io::Write, - path::{Component, Path, PathBuf}, -}; - -pub type Files = BTreeMap>; -const OWNERSHIP: &str = ".ownership.json"; - -/// The adopter explicitly chooses whether its generated tree records ownership. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Ownership { - /// Every existing file belongs to the dedicated generated tree. Updates - /// reject files absent from the candidate and write no additional metadata. - Dedicated, - /// A `.ownership.json` inventory distinguishes generated and unowned files. - Recorded, -} - -/// Finish all generation and validation before calling a publication operation. -/// This catches Rust panics, not process termination or side effects in user code. -pub fn prepare(generate: impl FnOnce() -> Result) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(generate)).map_err(|payload| { - let detail = payload - .downcast_ref::() - .map(String::as_str) - .or_else(|| payload.downcast_ref::<&str>().copied()) - .unwrap_or("unknown panic payload"); - format!("Documentation generation panicked; no output was published: {detail}") - })? -} - -/// Insert once, validating the application-owned relative output path. -pub fn insert(files: &mut Files, path: String, bytes: Vec) -> Result<()> { - validate_path(&path)?; - match files.entry(path) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(bytes); - Ok(()) - } - std::collections::btree_map::Entry::Occupied(entry) => { - Err(format!("Duplicate artifact owner: {}", entry.key())) - } - } -} - -/// Check a complete registration inventory independently of output rendering. -pub fn check_inventory(expected: &BTreeSet, current: &BTreeSet) -> Result<()> { - if expected == current { - return Ok(()); - } - Err(format!( - "Artifact inventory differs. Missing: {:?}; unregistered: {:?}", - expected.difference(current).collect::>(), - current.difference(expected).collect::>() - )) -} - -/// Read a dedicated tree verbatim. Absent roots produce an empty inventory. -/// Ownership metadata, when present, is an ordinary file in this raw view. -/// A sibling writer lock or interrupted transaction prevents a partial read. -pub fn read_tree(path: &Path) -> Result { - let transaction = Transaction::paths(path)?; - transaction.readable()?; - read_all(&transaction.target) -} - -/// Compare the complete expected inventory and every byte with a retained tree. -/// Application-specific profile and format checks remain the caller's policy. -pub fn compare(expected: &Files, current: &Files) -> Result<()> { - let keys: BTreeSet<_> = current.keys().chain(expected.keys()).collect(); - let changed: Vec<_> = keys - .into_iter() - .filter_map(|name| match (current.get(name), expected.get(name)) { - (None, Some(_)) => Some(format!("missing {name}")), - (Some(_), None) => Some(format!("extra {name}")), - (Some(a), Some(b)) if a != b => Some(format!("changed {name}")), - _ => None, - }) - .collect(); - if changed.is_empty() { - Ok(()) - } else { - Err(format!("Documentation drift: {}", changed.join(", "))) - } -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OwnershipRecord { - schema_version: u32, - files: Vec, -} - -fn io_error(action: &str, path: &Path, error: std::io::Error) -> String { - format!("{action} {}: {error}", path.display()) -} - -/// Normalize an output path for adapter preflight without creating it. -/// Rejects parent traversal, filesystem roots, and existing symlink components. -/// Publication repeats these checks; this does not reserve the destination. -pub fn absolute(path: &Path) -> Result { - let clean = absolute_base(path)?; - if clean.file_name().is_none() { - return Err("An output directory must have a name; filesystem roots are forbidden".into()); - } - Ok(clean) -} - -// A path-resolution base may be the filesystem root; an output tree may not. -pub(crate) fn absolute_base(path: &Path) -> Result { - let full = if path.is_absolute() { - path.to_owned() - } else { - std::env::current_dir() - .map_err(|e| e.to_string())? - .join(path) - }; - if full.components().any(|p| matches!(p, Component::ParentDir)) { - return Err(format!( - "Parent traversal is not allowed: {}", - path.display() - )); - } - let clean: PathBuf = full - .components() - .filter(|p| !matches!(p, Component::CurDir)) - .collect(); - no_symlinks(&clean)?; - Ok(clean) -} - -/// Reject overlapping output trees before generation, without creating paths. -/// Existing targets are compared with the other path's existing ancestors by -/// filesystem identity, so case aliases cannot hide a retained directory. -/// Missing targets have no identity yet and receive lexical checks only. This -/// preflight assumes a cooperative filesystem; it neither reserves paths nor -/// protects against another process changing their identity after the check. -pub fn ensure_separate_paths(left: &Path, right: &Path) -> Result<()> { - let left = absolute(left)?; - let right = absolute(right)?; - let overlap = || { - format!( - "Output paths must not overlap: {} and {}", - left.display(), - right.display() - ) - }; - if left.starts_with(&right) || right.starts_with(&left) { - return Err(overlap()); - } - for (target, other) in [(&left, &right), (&right, &left)] { - if !exists(target)? { - continue; - } - for ancestor in other.ancestors() { - if exists(ancestor)? - && same_file::is_same_file(target, ancestor) - .map_err(|error| io_error("Compare path identity", ancestor, error))? - { - return Err(overlap()); - } - } - } - Ok(()) -} - -fn no_symlinks(path: &Path) -> Result<()> { - let mut part = PathBuf::new(); - for component in path.components() { - part.push(component.as_os_str()); - match fs::symlink_metadata(&part) { - Ok(meta) if meta.file_type().is_symlink() => { - return Err(format!( - "Symlinks are forbidden in output paths: {}", - part.display() - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("Inspect", &part, error)), - } - } - Ok(()) -} - -fn exists(path: &Path) -> Result { - match fs::symlink_metadata(path) { - Ok(_) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(io_error("Inspect", path, error)), - } -} - -pub fn validate_path(key: &str) -> Result<()> { - if key.is_empty() - || key.contains('\\') - || key - .split('/') - .any(|s| s.is_empty() || s == "." || s == "..") - || Path::new(key) - .components() - .any(|p| !matches!(p, Component::Normal(_))) - { - return Err(format!("Unsafe or reserved output path: {key:?}")); - } - Ok(()) -} - -fn validate(files: &Files, ownership: Ownership) -> Result<()> { - for key in files.keys() { - validate_path(key)?; - if ownership == Ownership::Recorded && key.split('/').next() == Some(OWNERSHIP) { - return Err(format!("Reserved ownership path: {key}")); - } - let mut parent = Path::new(key).parent(); - while let Some(path) = parent.filter(|p| !p.as_os_str().is_empty()) { - if files.contains_key(path.to_str().ok_or("Non UTF-8 output path")?) { - return Err(format!( - "Output file conflicts with a parent directory: {key}" - )); - } - parent = path.parent(); - } - } - Ok(()) -} - -fn owned_tree(path: &Path, ownership: Ownership) -> Result { - let (files, missing) = owned_tree_inventory(path, ownership)?; - require_complete_ownership(&missing)?; - Ok(files) -} - -fn require_complete_ownership(missing: &BTreeSet) -> Result<()> { - if let Some(name) = missing.first() { - return Err(format!("Ownership inventory: missing {name}")); - } - Ok(()) -} - -fn owned_tree_inventory(path: &Path, ownership: Ownership) -> Result<(Files, BTreeSet)> { - if ownership == Ownership::Dedicated { - return Ok((read_all(path)?, BTreeSet::new())); - } - no_symlinks(path)?; - let marker = path.join(OWNERSHIP); - no_symlinks(&marker)?; - let bytes = fs::read(&marker).map_err(|e| io_error("Read ownership marker", &marker, e))?; - let record: OwnershipRecord = serde_json::from_slice(&bytes) - .map_err(|e| format!("Invalid ownership marker {}: {e}", marker.display()))?; - if record.schema_version != 1 { - return Err("Unsupported ownership schema".into()); - } - let mut expected = BTreeSet::new(); - for name in record.files { - validate_path(&name)?; - if name.split('/').next() == Some(OWNERSHIP) { - return Err(format!("Reserved ownership path: {name}")); - } - if !expected.insert(name.clone()) { - return Err(format!("Duplicate owned path: {name}")); - } - } - let mut files = Files::new(); - visit(path, path, Some(&expected), &mut files)?; - let missing = expected - .into_iter() - .filter(|name| !files.contains_key(name)) - .collect(); - Ok((files, missing)) -} - -fn visit( - base: &Path, - dir: &Path, - expected: Option<&BTreeSet>, - files: &mut Files, -) -> Result<()> { - no_symlinks(dir)?; - for entry in fs::read_dir(dir).map_err(|e| io_error("Read directory", dir, e))? { - let entry = entry.map_err(|e| e.to_string())?; - let full = entry.path(); - let name = inventory_path(full.strip_prefix(base).map_err(|e| e.to_string())?)?; - let kind = entry - .file_type() - .map_err(|e| io_error("Inspect", &full, e))?; - if kind.is_symlink() { - return Err(format!("Output symlink is forbidden: {}", full.display())); - } - if kind.is_dir() { - if let Some(expected) = expected - && !expected.iter().any(|p| p.starts_with(&format!("{name}/"))) - { - return Err(format!("Unowned output directory: {name}")); - } - visit(base, &full, expected, files)?; - } else if kind.is_file() { - if let Some(expected) = expected { - if name == OWNERSHIP { - continue; - } - if !expected.contains(&name) { - return Err(format!("Unowned output file: {name}")); - } - } - insert( - files, - name, - fs::read(&full).map_err(|e| io_error("Read", &full, e))?, - )?; - } else { - return Err(format!("Unsupported output file type: {}", full.display())); - } - } - Ok(()) -} - -// Inventories use `/` on every host, while filesystem paths use the native -// separator. Convert components rather than replacing characters: a literal -// backslash in a Unix filename must still fail the output-path policy. -fn inventory_path(path: &Path) -> Result { - let name = path - .components() - .map(|component| match component { - Component::Normal(name) => name.to_str().ok_or("Non UTF-8 output path"), - _ => Err("Unexpected non-relative output path component"), - }) - .collect::, _>>()? - .join("/"); - validate_path(&name)?; - Ok(name) -} - -fn read_all(path: &Path) -> Result { - no_symlinks(path)?; - let mut files = Files::new(); - if exists(path)? { - visit(path, path, None, &mut files)?; - } - Ok(files) -} - -struct Transaction { - target: PathBuf, - lock: PathBuf, - stage: PathBuf, - backup: PathBuf, - locked: bool, -} - -// Each published tree reserves these sibling paths for its transaction. Runner -// preflight must keep other outputs out of all four locations, not just target. -pub(crate) fn transaction_paths(path: &Path) -> Result<[PathBuf; 4]> { - let transaction = Transaction::paths(path)?; - Ok([ - transaction.target.clone(), - transaction.lock.clone(), - transaction.stage.clone(), - transaction.backup.clone(), - ]) -} - -impl Transaction { - fn paths(path: &Path) -> Result { - let target = absolute(path)?; - let name = target - .file_name() - .and_then(|s| s.to_str()) - .ok_or("Non UTF-8 destination")?; - let parent = target.parent().ok_or("Destination has no parent")?; - Ok(Self { - lock: parent.join(format!(".{name}.lock")), - stage: parent.join(format!(".{name}.staging")), - backup: parent.join(format!(".{name}.backup")), - target, - locked: false, - }) - } - - fn acquire(path: &Path) -> Result { - let mut transaction = Self::paths(path)?; - let parent = transaction.target.parent().unwrap(); - fs::create_dir_all(parent).map_err(|e| io_error("Create output parent", parent, e))?; - no_symlinks(parent)?; - for path in [&transaction.lock, &transaction.stage, &transaction.backup] { - no_symlinks(path)?; - } - let mut lock = OpenOptions::new().write(true).create_new(true).open(&transaction.lock) - .map_err(|e| format!("Output lock unavailable at {}: {e}; inspect an interrupted run before removing a stale lock", transaction.lock.display()))?; - if let Err(error) = writeln!(lock, "pid={}", std::process::id()) { - let _ = fs::remove_file(&transaction.lock); - return Err(io_error("Write output lock", &transaction.lock, error)); - } - transaction.locked = true; - Ok(transaction) - } - - fn clean_state(&self) -> Result<()> { - for path in [&self.stage, &self.backup] { - no_symlinks(path)?; - if exists(path)? { - return Err(format!( - "Interrupted transaction material at {}; preserve and inspect it before retrying (recover_backup restores a valid backup only when the destination is absent)", - path.display() - )); - } - } - Ok(()) - } - - fn readable(&self) -> Result<()> { - no_symlinks(&self.lock)?; - if exists(&self.lock)? { - return Err(format!( - "Cannot check during an update or stale lock: {}", - self.lock.display() - )); - } - self.clean_state() - } -} - -impl Drop for Transaction { - fn drop(&mut self) { - if self.locked { - let _ = fs::remove_file(&self.lock); - } - } -} - -fn write_file(path: &Path, bytes: &[u8]) -> Result<()> { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|e| io_error("Create staged file", path, e))?; - file.write_all(bytes) - .map_err(|e| io_error("Write staged file", path, e))?; - file.sync_all() - .map_err(|e| io_error("Sync staged file", path, e)) -} - -fn stage(transaction: &Transaction, files: &Files, ownership: Ownership) -> Result<()> { - fs::create_dir(&transaction.stage) - .map_err(|e| io_error("Create staging directory", &transaction.stage, e))?; - let result = (|| { - for (name, bytes) in files { - let file = transaction.stage.join(name); - fs::create_dir_all(file.parent().unwrap()) - .map_err(|e| io_error("Create staged directory", &file, e))?; - write_file(&file, bytes)?; - } - if ownership == Ownership::Recorded { - let marker = serde_json::to_vec_pretty(&OwnershipRecord { - schema_version: 1, - files: files.keys().cloned().collect(), - }) - .map_err(|e| e.to_string())?; - write_file(&transaction.stage.join(OWNERSHIP), &marker)?; - } - if owned_tree(&transaction.stage, ownership)? != *files { - return Err("Staged output did not match the validated candidate".into()); - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(&transaction.stage); - } - result -} - -/// Compare a complete retained output without modifying it or creating a lock. -/// The callback receives (retained, candidate) before exact byte comparison. -pub fn check( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files) -> Result<()>, -) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::paths(path)?; - transaction.readable()?; - let current = owned_tree(&transaction.target, ownership)?; - validate_current(¤t, files)?; - compare(files, ¤t) -} - -/// Write a complete candidate into a new directory; never overwrite an existing one. -pub fn build(path: &Path, files: &Files, ownership: Ownership) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::acquire(path)?; - transaction.clean_state()?; - if exists(&transaction.target)? { - return Err(format!( - "Build destination already exists: {}", - transaction.target.display() - )); - } - stage(&transaction, files, ownership)?; - if let Err(error) = fs::rename(&transaction.stage, &transaction.target) { - let _ = fs::remove_dir_all(&transaction.stage); - return Err(io_error("Publish candidate", &transaction.target, error)); - } - Ok(()) -} - -/// Validate and stage all output before replacing a compatible owned baseline. -pub fn update( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files) -> Result<()>, -) -> Result<()> { - update_inner( - path, - files, - ownership, - |current, candidate, missing| { - require_complete_ownership(missing)?; - validate_current(current, candidate) - }, - || Ok(()), - ) -} - -// The structured store can validate explicit retirement against its retained -// manifest. Generic lifecycle callers have no such evidence and remain strict. -pub(crate) fn update_with_retirement( - path: &Path, - files: &Files, - validate_current: impl FnOnce(&Files, &Files, &BTreeSet) -> Result<()>, -) -> Result<()> { - update_inner( - path, - files, - Ownership::Recorded, - validate_current, - || Ok(()), - ) -} - -fn update_inner( - path: &Path, - files: &Files, - ownership: Ownership, - validate_current: impl FnOnce(&Files, &Files, &BTreeSet) -> Result<()>, - before_publish: impl FnOnce() -> Result<()>, -) -> Result<()> { - validate(files, ownership)?; - let transaction = Transaction::acquire(path)?; - transaction.clean_state()?; - let had_baseline = exists(&transaction.target)?; - if had_baseline { - let (current, missing) = owned_tree_inventory(&transaction.target, ownership)?; - validate_current(¤t, files, &missing)?; - let obsolete: Vec<_> = current - .keys() - .filter(|name| !files.contains_key(*name)) - .collect(); - if !obsolete.is_empty() { - return Err(format!( - "Obsolete owned files require explicit removal before update: {obsolete:?}" - )); - } - } - stage(&transaction, files, ownership)?; - if had_baseline && let Err(error) = fs::rename(&transaction.target, &transaction.backup) { - let _ = fs::remove_dir_all(&transaction.stage); - return Err(io_error( - "Preserve baseline backup", - &transaction.backup, - error, - )); - } - let publication = before_publish().and_then(|()| { - fs::rename(&transaction.stage, &transaction.target) - .map_err(|e| io_error("Publish baseline", &transaction.target, e)) - }); - if let Err(error) = publication { - if had_baseline && let Err(rollback) = fs::rename(&transaction.backup, &transaction.target) - { - return Err(format!( - "{error}; rollback failed: {rollback}; preserved backup: {}; staged candidate: {}", - transaction.backup.display(), - transaction.stage.display() - )); - } - let _ = fs::remove_dir_all(&transaction.stage); - return Err(error); - } - if had_baseline { - fs::remove_dir_all(&transaction.backup).map_err(|e| { - io_error( - "Baseline published but backup cleanup failed; inspect preserved backup", - &transaction.backup, - e, - ) - })?; - } - Ok(()) -} - -/// Explicit recovery for the unambiguous interrupted state with no destination. -/// An existing destination is never replaced, and staged candidates are retained. -pub fn recover_backup( - path: &Path, - ownership: Ownership, - validate_backup: impl FnOnce(&Files) -> Result<()>, -) -> Result<()> { - let transaction = Transaction::acquire(path)?; - if exists(&transaction.target)? { - return Err( - "Recovery refused: destination exists; inspect it and the backup explicitly".into(), - ); - } - if !exists(&transaction.backup)? { - return Err("Recovery refused: no preserved backup exists".into()); - } - let backup = owned_tree(&transaction.backup, ownership)?; - validate(&backup, ownership)?; - validate_backup(&backup)?; - fs::rename(&transaction.backup, &transaction.target) - .map_err(|e| io_error("Restore preserved backup", &transaction.target, e)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inventory_paths_join_native_components_with_portable_separators() { - let native = Path::new("assets").join("guide").join("evidence.txt"); - assert_eq!( - inventory_path(&native).unwrap(), - "assets/guide/evidence.txt" - ); - assert!(inventory_path(Path::new("../outside")).is_err()); - } - - #[cfg(unix)] - #[test] - fn inventory_paths_do_not_reinterpret_unix_backslash_filenames() { - assert!(inventory_path(Path::new(r"assets\evidence.txt")).is_err()); - } - - #[test] - fn failed_publication_restores_previous_baseline() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap().join("baseline"); - let mut before = Files::from([ - ( - "manifest.json".into(), - br#"{"schema_version":1,"audience":"reader","profiles":[],"artifacts":[]}"# - .to_vec(), - ), - ("page.md".into(), b"before".to_vec()), - ]); - update(&root, &before, Ownership::Dedicated, |_, _| Ok(())).unwrap(); - let old = before.clone(); - before.insert("page.md".into(), b"after".to_vec()); - let error = update_inner( - &root, - &before, - Ownership::Dedicated, - |_, _, _| Ok(()), - || Err("injected publish I/O failure".into()), - ) - .unwrap_err(); - assert!(error.contains("injected")); - check(&root, &old, Ownership::Dedicated, |_, _| Ok(())).unwrap(); - assert!(!root.parent().unwrap().join(".baseline.backup").exists()); - assert!(!root.parent().unwrap().join(".baseline.staging").exists()); - } -} diff --git a/crates/doc-harness/src/manifest.rs b/crates/doc-harness/src/manifest.rs deleted file mode 100644 index 1ccf7ea..0000000 --- a/crates/doc-harness/src/manifest.rs +++ /dev/null @@ -1,514 +0,0 @@ -//! The existing export receipt: typed emission and tolerant, strict validation. -//! Unknown JSON fields remain accepted; validation retains the original protocol -//! rules before typed metadata is used by publication compatibility checks. -use crate::{ - Artifact, Audience, Result, - lifecycle::{self, Files}, - model::{Content, Document, Node}, - validation::visible_nodes, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::{ - collections::{BTreeMap, BTreeSet}, - path::Path, -}; - -#[derive(Serialize, Deserialize)] -pub(crate) struct Manifest { - schema_version: u64, - pub(crate) audience: String, - profiles: Vec, - documents: Vec, - pub(crate) artifacts: Vec, -} -#[derive(Serialize, Deserialize)] -struct Profile { - producer: String, - profile: String, -} -#[derive(Serialize, Deserialize)] -struct DocumentRecord { - id: String, - title: String, - checks: Vec, - blocks: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - bindings: Option>, -} -#[derive(Serialize, Deserialize)] -struct CheckRecord { - id: String, - kind: String, - passed: bool, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - actual: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "present_value" - )] - expected: Option, -} -// JSON null is a present observation, distinct from an omitted private payload. -fn present_value<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - Value::deserialize(deserializer).map(Some) -} -#[derive(Serialize, Deserialize)] -struct BlockRecord { - id: String, - kind: String, - references: Vec, -} -#[derive(Serialize, Deserialize)] -struct ReferenceRecord { - id: String, - kind: String, -} -#[derive(Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub(crate) enum ArtifactRecord { - Page { - path: String, - document: String, - mime: String, - bytes: u64, - sha256: String, - }, - Resource { - path: String, - document: String, - id: String, - mime: String, - producer: String, - profile: String, - bytes: u64, - sha256: String, - }, -} -impl ArtifactRecord { - pub(crate) fn path(&self) -> &str { - match self { - Self::Page { path, .. } | Self::Resource { path, .. } => path, - } - } -} -fn sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} -impl Manifest { - pub(crate) fn new(audience: Audience) -> Self { - Self { - schema_version: 1, - audience: match audience { - Audience::Reader => "reader", - Audience::Contributor => "contributor", - } - .into(), - profiles: Vec::new(), - documents: Vec::new(), - artifacts: Vec::new(), - } - } - pub(crate) fn add_page(&mut self, document: &str, path: &str, bytes: &[u8]) { - self.artifacts.push(ArtifactRecord::Page { - path: path.into(), - document: document.into(), - mime: "text/markdown".into(), - bytes: bytes.len() as u64, - sha256: sha256(bytes), - }); - } - pub(crate) fn add_resource( - &mut self, - document: &str, - id: &str, - path: &str, - artifact: &Artifact, - ) { - self.artifacts.push(ArtifactRecord::Resource { - path: path.into(), - document: document.into(), - id: id.into(), - mime: artifact.mime.clone(), - producer: artifact.producer.clone(), - profile: artifact.profile.clone(), - bytes: artifact.bytes.len() as u64, - sha256: sha256(&artifact.bytes), - }); - } - pub(crate) fn add_document(&mut self, document: &Document, audience: Audience) -> Result<()> { - let reader_refs: BTreeSet<_> = document - .nodes - .iter() - .filter(|node| !node.is_note()) - .flat_map(Node::references) - .map(|r| &r.id) - .collect(); - let checks = document - .checks - .values() - .map(|check| { - let reveal = audience == Audience::Contributor || reader_refs.contains(&check.id); - CheckRecord { - id: check.id.clone(), - kind: check.kind.clone(), - passed: true, - actual: reveal.then(|| check.actual.clone()), - expected: reveal.then(|| check.expected.clone()), - } - }) - .collect(); - let blocks = visible_nodes(document, audience) - .map(|node| { - let kind = match &node.content { - Content::Heading(..) => "heading", - Content::Paragraph(..) => "paragraph", - Content::Markdown(..) => "markdown", - Content::MarkdownParts(..) => "markdown-parts", - Content::Embed(..) => "embed", - Content::Code(..) => "code", - Content::Note { .. } => "note", - }; - let references = node - .references() - .into_iter() - .map(|r| { - Ok(ReferenceRecord { - id: r.id.clone(), - kind: serde_json::to_value(&r.kind) - .map_err(|e| e.to_string())? - .as_str() - .ok_or("Invalid reference kind")? - .into(), - }) - }) - .collect::>>()?; - Ok(BlockRecord { - id: node.id.clone(), - kind: kind.into(), - references, - }) - }) - .collect::>>()?; - let visible_refs: BTreeSet<_> = visible_nodes(document, audience) - .flat_map(Node::references) - .map(|r| &r.id) - .collect(); - let bindings = document - .bindings - .iter() - .filter(|(id, _)| visible_refs.contains(id)) - .map(|(id, value)| { - Ok(( - id.clone(), - serde_json::to_value(value).map_err(|e| e.to_string())?, - )) - }) - .collect::>>()?; - self.documents.push(DocumentRecord { - id: document.id.clone(), - title: document.title.clone(), - checks, - blocks, - bindings: (!bindings.is_empty()).then_some(bindings), - }); - Ok(()) - } - pub(crate) fn encode(mut self) -> Result> { - self.artifacts.sort_by(|a, b| a.path().cmp(b.path())); - let profiles: BTreeSet<_> = self - .artifacts - .iter() - .filter_map(|artifact| match artifact { - ArtifactRecord::Resource { - producer, profile, .. - } => Some((producer.clone(), profile.clone())), - _ => None, - }) - .collect(); - self.profiles = profiles - .into_iter() - .map(|(producer, profile)| Profile { producer, profile }) - .collect(); - let mut value = serde_json::to_value(self).map_err(|e| e.to_string())?; - value.sort_all_objects(); - let mut bytes = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - Ok(bytes) - } - pub(crate) fn read(files: &Files) -> Result { - serde_json::from_value(raw_manifest(files)?) - .map_err(|e| format!("Invalid manifest.json: {e}")) - } - pub(crate) fn resource_profiles(&self) -> Result> { - let mut profiles = BTreeMap::new(); - for artifact in &self.artifacts { - if let ArtifactRecord::Resource { - document, - id, - producer, - profile, - .. - } = artifact - { - let identity = (document.clone(), id.clone()); - if profiles - .insert(identity.clone(), (producer.clone(), profile.clone())) - .is_some() - { - return Err(format!( - "Duplicate resource manifest identity: {identity:?}" - )); - } - } - } - Ok(profiles) - } -} - -fn raw_manifest(files: &Files) -> Result { - let bytes = files.get("manifest.json").ok_or("Missing manifest.json")?; - let value: serde_json::Value = - serde_json::from_slice(bytes).map_err(|e| format!("Invalid manifest.json: {e}"))?; - if value["schema_version"] != 1 { - return Err(format!( - "Unsupported manifest schema: {}", - value["schema_version"] - )); - } - if !matches!(value["audience"].as_str(), Some("reader" | "contributor")) { - return Err("Manifest audience must be reader or contributor".into()); - } - if !value["profiles"].is_array() { - return Err("Manifest profiles must be an array".into()); - } - Ok(value) -} - -fn field<'a>(record: &'a Value, name: &str) -> Result<&'a str> { - record[name] - .as_str() - .filter(|text| !text.is_empty()) - .ok_or_else(|| format!("Manifest requires nonempty string field {name}")) -} - -fn array<'a>(record: &'a Value, name: &str) -> Result<&'a Vec> { - record[name] - .as_array() - .ok_or_else(|| format!("Manifest {name} must be an array")) -} - -fn identity(identities: &mut BTreeMap, id: &str, kind: &str) -> Result<()> { - if !crate::model::valid_id(id) { - return Err(format!("Invalid manifest evidence ID: {id}")); - } - if identities.insert(id.into(), kind.into()).is_some() { - return Err(format!("Duplicate manifest evidence identity: {id}")); - } - Ok(()) -} - -pub(crate) fn validate_tree(files: &Files, retired_from: Option<&Files>) -> Result<()> { - // Check map paths before reading metadata, so no malformed candidate can - // reach staging merely by omitting that path from its artifact inventory. - for path in files.keys() { - lifecycle::validate_path(path)?; - if path.split('/').next() == Some(".ownership.json") { - return Err(format!("Reserved ownership path: {path}")); - } - for parent in Path::new(path) - .ancestors() - .skip(1) - .filter(|p| !p.as_os_str().is_empty()) - { - if files.contains_key(parent.to_str().ok_or("Non UTF-8 output path")?) { - return Err(format!( - "Output file conflicts with a parent directory: {path}" - )); - } - } - } - let manifest = raw_manifest(files)?; - let documents = array(&manifest, "documents")?; - if documents.is_empty() { - return Err("Manifest must contain at least one document".into()); - } - let mut identities = BTreeMap::new(); - for document in documents { - let id = field(document, "id")?; - if !crate::model::valid_id(id) { - return Err(format!("Invalid manifest document ID: {id}")); - } - field(document, "title")?; - let mut local = BTreeMap::new(); - let checks = array(document, "checks")?; - if checks.is_empty() { - return Err(format!("Manifest document {id} has no checks")); - } - for check in checks { - let kind = match field(check, "kind")? { - "equality" => "value", - "predicate" => "claim", - other => return Err(format!("Unknown manifest check kind: {other}")), - }; - identity(&mut local, field(check, "id")?, kind)?; - if check["passed"] != true { - return Err(format!( - "Manifest document {id} contains a failed or missing check result" - )); - } - if check.get("actual").is_some() != check.get("expected").is_some() { - return Err( - "Manifest check must include both actual and expected payloads, or neither" - .into(), - ); - } - } - for block in array(document, "blocks")? { - let kind = field(block, "kind")?; - if !matches!( - kind, - "heading" | "paragraph" | "markdown" | "markdown-parts" | "embed" | "code" | "note" - ) { - return Err(format!("Unknown manifest block kind: {kind}")); - } - if manifest["audience"] == "reader" && kind == "note" { - return Err("Reader manifest cannot contain contributor notes".into()); - } - identity(&mut local, field(block, "id")?, "block")?; - array(block, "references")?; - } - if let Some(bindings) = document.get("bindings") { - for (binding, value) in bindings - .as_object() - .ok_or("Manifest bindings must be an object")? - { - identity(&mut local, binding, "binding")?; - let valid_label = - |label: &str| !label.is_empty() && !label.chars().any(char::is_control); - let valid = match field(value, "kind")? { - "text" | "code" => value["value"].as_str().is_some_and(valid_label), - "keys" => value["value"].as_array().is_some_and(|keys| { - !keys.is_empty() - && keys.iter().all(|key| key.as_str().is_some_and(valid_label)) - }), - _ => false, - }; - if !valid { - return Err(format!("Invalid manifest binding: {binding}")); - } - } - } - if identities.insert(id.to_owned(), local).is_some() { - return Err(format!("Duplicate manifest document identity: {id}")); - } - } - let mut paths = BTreeSet::new(); - let mut pages = BTreeSet::new(); - let mut profiles = BTreeSet::new(); - for artifact in array(&manifest, "artifacts")? { - let path = field(artifact, "path")?; - crate::paths::validate_resource_path(path)?; - if path.split('/').next() == Some(".ownership.json") { - return Err(format!("Reserved ownership path in manifest: {path}")); - } - if !paths.insert(path.to_owned()) { - return Err(format!("Duplicate manifest artifact path: {path}")); - } - let document = field(artifact, "document")?; - let local = identities - .get_mut(document) - .ok_or_else(|| format!("Artifact {path} references unknown document {document}"))?; - let mime = field(artifact, "mime")?; - match field(artifact, "kind")? { - "page" => { - // The manifest binds each document to its exported page. Page - // placement belongs to the adopter, not to the document ID. - if mime != "text/markdown" { - return Err(format!("Invalid document page MIME: {path}")); - } - if !pages.insert(document.to_owned()) { - return Err(format!("Duplicate document page identity: {document}")); - } - } - "resource" => { - identity(local, field(artifact, "id")?, "resource")?; - let producer = field(artifact, "producer")?; - let profile = field(artifact, "profile")?; - crate::Artifact::new(Vec::new(), mime, "bin", producer, profile)?; - profiles.insert((producer.to_owned(), profile.to_owned())); - } - other => return Err(format!("Unknown manifest artifact kind: {other}")), - } - let size = artifact["bytes"] - .as_u64() - .ok_or_else(|| format!("Manifest artifact {path} requires a byte length"))?; - let digest = field(artifact, "sha256")?; - if digest.len() != 64 - || !digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(format!( - "Manifest artifact {path} requires a lowercase SHA-256" - )); - } - if let Some(bytes) = files.get(path) { - if size != bytes.len() as u64 || digest != format!("{:x}", Sha256::digest(bytes)) { - return Err(format!("Manifest artifact size/hash mismatch: {path}")); - } - } else if retired_from.is_none_or(|candidate| candidate.contains_key(path)) { - return Err(format!("Manifest artifact inventory: missing {path}")); - } - } - if pages != identities.keys().cloned().collect() { - return Err("Manifest must own exactly one page for every document".into()); - } - let extra: Vec<_> = files - .keys() - .filter(|path| path.as_str() != "manifest.json" && !paths.contains(*path)) - .collect(); - if !extra.is_empty() { - return Err(format!( - "Manifest artifact inventory: unrecorded paths {extra:?}" - )); - } - let mut declared_profiles = BTreeSet::new(); - for profile in array(&manifest, "profiles")? { - let pair = ( - field(profile, "producer")?.to_owned(), - field(profile, "profile")?.to_owned(), - ); - if !declared_profiles.insert(pair) { - return Err("Duplicate manifest producer/profile pair".into()); - } - } - if profiles != declared_profiles { - return Err( - "Manifest producer/profile inventory does not match its resource metadata".into(), - ); - } - for document in documents { - let id = field(document, "id")?; - let local = &identities[id]; - for block in array(document, "blocks")? { - for reference in array(block, "references")? { - let target = field(reference, "id")?; - if local.get(target).map(String::as_str) != Some(field(reference, "kind")?) { - return Err(format!( - "Manifest reference from {id} is missing or has the wrong kind: {target}" - )); - } - } - } - } - Ok(()) -} diff --git a/crates/doc-harness/src/markdown.rs b/crates/doc-harness/src/markdown.rs deleted file mode 100644 index af39104..0000000 --- a/crates/doc-harness/src/markdown.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! CommonMark validation and provenance for framework-generated HTML. -use crate::{ - Result, - model::{Inline, Prose}, -}; -use pulldown_cmark::{BrokenLink, Event, Options, Parser, Tag}; -use std::ops::Range; - -/// Keep provenance for the small amount of HTML emitted by typed bindings and -/// anchors. Authored Markdown never acquires that permission by sharing a page. -#[derive(Default)] -pub(crate) struct Rendered { - pub(crate) text: String, - html: Vec>, -} - -impl Rendered { - pub(crate) fn text(text: impl Into) -> Self { - Self { - text: text.into(), - html: Vec::new(), - } - } - - pub(crate) fn html(text: String) -> Self { - let len = text.len(); - Self { - text, - html: std::iter::once(0..len).collect(), - } - } - - pub(crate) fn push(&mut self, other: Self) { - let offset = self.text.len(); - self.text.push_str(&other.text); - self.html.extend( - other - .html - .into_iter() - .map(|range| range.start + offset..range.end + offset), - ); - } - - pub(crate) fn quote(self) -> Self { - let mut result = Self::default(); - let mut offset = 0; - for line in self.text.split_inclusive('\n') { - let start = result.text.len() + 2; - result.text.push_str("> "); - result.text.push_str(line); - for range in &self.html { - let left = range.start.max(offset); - let right = range.end.min(offset + line.len()); - if left < right { - result - .html - .push(start + left - offset..start + right - offset); - } - } - offset += line.len(); - } - // Match str::lines(), used by the original note renderer. - if result.text.ends_with('\n') { - result.text.pop(); - } - result - } -} - -pub(crate) fn markdown_links(text: &str) -> Result> { - parsed_links(&Rendered::text(text), &[]) -} - -/// Parse the bytes that will actually be exported. Every HTML token must come -/// from a generated span, every generated tag must survive Markdown parsing, -/// and code/container blocks cannot consume the next authored node. Plain -/// paragraphs may continue across MarkdownParts nodes, whose whitespace is literal. -pub(crate) fn parsed_links( - rendered: &Rendered, - boundaries: &[(usize, &str)], -) -> Result> { - let text = &rendered.text; - let mut unresolved = Vec::new(); - let mut callback = |link: BrokenLink<'_>| { - unresolved.push(link.reference.to_string()); - None - }; - let mut links = Vec::new(); - let mut html_ranges = Vec::new(); - for (event, range) in Parser::new_with_broken_link_callback( - text, - Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH, - Some(&mut callback), - ) - .into_offset_iter() - { - if matches!( - &event, - Event::Code(_) - | Event::Start( - Tag::CodeBlock(_) - | Tag::HtmlBlock - | Tag::BlockQuote(_) - | Tag::List(_) - | Tag::Item - | Tag::Table(_) - | Tag::TableHead - | Tag::TableRow - ) - ) && boundaries - .iter() - .any(|(boundary, _)| range.start < *boundary && *boundary < range.end) - { - return Err(format!( - "Markdown block crosses a document block boundary at {range:?} ({event:?}){}; close fences and separate blocks explicitly", - block_context(range.start, boundaries), - )); - } - match event { - Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => { - links.push(dest_url.to_string()) - } - Event::Html(_) | Event::InlineHtml(_) => { - if (range.clone()).any(|position| { - !text.as_bytes()[position].is_ascii_whitespace() - && !rendered - .html - .iter() - .any(|allowed| allowed.contains(&position)) - }) { - return Err(format!( - "Raw HTML is unsupported in authored Markdown{} at {range:?}; use typed document blocks", - block_context(range.start, boundaries), - )); - } - html_ranges.push(range); - } - _ => {} - } - } - if !unresolved.is_empty() { - return Err(format!( - "Unresolved Markdown references: {}", - unresolved.join(", ") - )); - } - if let Some(expected) = rendered.html.iter().find(|expected| { - (*expected).clone().any(|position| { - !text.as_bytes()[position].is_ascii_whitespace() - && !html_ranges.iter().any(|actual| actual.contains(&position)) - }) - }) { - return Err(format!( - "Markdown swallowed a generated binding or block anchor{} at {expected:?}; typed HTML cannot be placed inside code or HTML syntax", - block_context(expected.start, boundaries), - )); - } - Ok(links) -} - -fn block_context(position: usize, boundaries: &[(usize, &str)]) -> String { - boundaries - .iter() - .rev() - .find(|(start, _)| *start <= position) - .map(|(_, id)| format!(" in block {id}")) - .unwrap_or_default() -} - -pub(crate) fn validate_markdown_parts(parts: &Prose) -> Result<()> { - let template: String = parts - .0 - .iter() - .map(|part| match part { - Inline::Text(text) => text.as_str(), - _ => "EXECUTABLEDOCREFERENCE", - }) - .collect(); - markdown_links(&template).map(|_| ()) -} - -pub(crate) fn escape(text: &str) -> String { - let mut result = String::new(); - for c in text.chars() { - match c { - '&' => result.push_str("&"), - '<' => result.push_str("<"), - '>' => result.push_str(">"), - '\\' | '`' | '~' | '*' | '_' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '!' | '|' - | '{' | '}' | '.' => { - result.push('\\'); - result.push(c); - } - _ => result.push(c), - } - } - result -} diff --git a/crates/doc-harness/src/model.rs b/crates/doc-harness/src/model.rs deleted file mode 100644 index 5ce57ea..0000000 --- a/crates/doc-harness/src/model.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! Frozen document data, typed handles, and prose values. - -use crate::{Artifact, Result}; -use serde::Serialize; -use serde_json::Value; -use std::{collections::BTreeMap, marker::PhantomData}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Audience { - Reader, - Contributor, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Kind { - Block, - Claim, - Value, - Resource, - Binding, -} - -#[derive(Clone, Debug)] -pub struct Reference { - pub(crate) owner: u64, - pub(crate) id: String, - pub(crate) kind: Kind, -} - -/// A handle can only originate from this crate and the document that owns it. -pub trait Target: private::Sealed { - #[doc(hidden)] - fn reference(&self) -> Reference; -} - -mod private { - pub trait Sealed {} -} - -macro_rules! handle { - ($name:ident) => { - #[derive(Clone, Debug)] - pub struct $name(pub(crate) Reference); - impl private::Sealed for $name {} - impl Target for $name { - fn reference(&self) -> Reference { - self.0.clone() - } - } - impl $name { - pub fn id(&self) -> &str { - &self.0.id - } - } - impl IntoProse for &$name { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Reference(self.reference())]) - } - } - impl IntoProse for $name { - fn into_prose(self) -> Prose { - (&self).into_prose() - } - } - }; -} - -handle!(Block); -handle!(Claim); -handle!(Resource); -handle!(Binding); - -impl Block { - /// Link to this block with a reader-facing label. Reader content cannot link - /// to contributor-only notes. - pub fn link(&self, label: &str) -> Prose { - Prose(vec![Inline::Link(self.reference(), label.into())]) - } -} - -/// An observed application label, not a behavioral assertion. The host adapter -/// must establish that the control or canonical shortcut was actually observed. -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "kind", content = "value", rename_all = "lowercase")] -pub enum BindingValue { - Text(String), - Code(String), - Keys(Vec), -} - -impl Resource { - /// Place this resource as an image inline. The owning builder checks its MIME. - pub fn image(&self, alt: &str) -> Prose { - Prose(vec![Inline::Image(self.reference(), alt.into())]) - } - - /// Link to this resource using a reader-facing label. - pub fn link(&self, label: &str) -> Prose { - Prose(vec![Inline::Link(self.reference(), label.into())]) - } -} - -/// The serialized observation is frozen when the equality check succeeds. -#[derive(Clone, Debug)] -pub struct Checked { - pub(crate) reference: Reference, - pub(crate) observed: Value, - pub(crate) marker: PhantomData T>, -} - -impl Checked { - pub fn id(&self) -> &str { - &self.reference.id - } - pub fn observed(&self) -> &Value { - &self.observed - } -} -impl private::Sealed for Checked {} -impl Target for Checked { - fn reference(&self) -> Reference { - self.reference.clone() - } -} -impl IntoProse for &Checked { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Reference(self.reference())]) - } -} -impl IntoProse for Checked { - fn into_prose(self) -> Prose { - (&self).into_prose() - } -} - -#[derive(Clone, Debug)] -pub(crate) enum Inline { - Text(String), - Reference(Reference), - Image(Reference, String), - Link(Reference, String), -} - -#[derive(Clone, Debug)] -pub struct Prose(pub(crate) Vec); - -/// Text is escaped as literal prose; typed references are resolved by the owner. -/// Tuples permit mixed text and references without placeholder names. -pub trait IntoProse { - fn into_prose(self) -> Prose; -} -impl IntoProse for Prose { - fn into_prose(self) -> Prose { - self - } -} -impl IntoProse for &str { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Text(self.into())]) - } -} -impl IntoProse for String { - fn into_prose(self) -> Prose { - Prose(vec![Inline::Text(self)]) - } -} -impl IntoProse for &String { - fn into_prose(self) -> Prose { - self.as_str().into_prose() - } -} -impl IntoProse for Vec { - fn into_prose(self) -> Prose { - Prose( - self.into_iter() - .flat_map(|part| part.into_prose().0) - .collect(), - ) - } -} -macro_rules! tuple_prose { - ($($ty:ident:$field:tt),+) => { - impl<$($ty: IntoProse),+> IntoProse for ($($ty,)+) { - fn into_prose(self) -> Prose { - let mut parts = Vec::new(); - $(parts.extend(self.$field.into_prose().0);)+ - Prose(parts) - } - } - }; -} -tuple_prose!(A:0, B:1); -tuple_prose!(A:0, B:1, C:2); -tuple_prose!(A:0, B:1, C:2, D:3); -tuple_prose!(A:0, B:1, C:2, D:3, E:4); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5, G:6); -tuple_prose!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7); - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct Check { - pub id: String, - pub kind: String, - pub actual: Value, - pub expected: Value, -} - -#[derive(Clone, Debug)] -pub(crate) enum Content { - Heading(u8, String), - Paragraph(Prose), - Markdown(String), - MarkdownParts(Prose), - Embed(Reference, String), - Code(Reference, String), - Note { - target: Option, - prose: Prose, - }, -} - -#[derive(Clone, Debug)] -pub(crate) struct Node { - pub id: String, - pub content: Content, -} - -impl Node { - pub(crate) fn is_note(&self) -> bool { - matches!(self.content, Content::Note { .. }) - } - pub(crate) fn references(&self) -> Vec<&Reference> { - let mut refs = Vec::new(); - match &self.content { - Content::Paragraph(prose) - | Content::MarkdownParts(prose) - | Content::Note { prose, .. } => { - refs.extend(prose.0.iter().filter_map(|part| match part { - Inline::Reference(r) | Inline::Image(r, _) | Inline::Link(r, _) => Some(r), - _ => None, - })); - } - Content::Embed(r, _) | Content::Code(r, _) => refs.push(r), - _ => {} - } - if let Content::Note { - target: Some(target), - .. - } = &self.content - { - refs.push(target); - } - refs - } -} - -/// An immutable successfully completed scenario/document. -#[derive(Clone, Debug)] -pub struct Document { - pub(crate) id: String, - pub(crate) title: String, - pub(crate) nodes: Vec, - pub(crate) checks: BTreeMap, - pub(crate) artifacts: BTreeMap, - pub(crate) resource_paths: BTreeMap, - pub(crate) bindings: BTreeMap, -} - -impl Document { - pub fn id(&self) -> &str { - &self.id - } - pub fn render_many( - documents: &[Self], - audience: Audience, - ) -> Result>> { - crate::render(documents, audience) - } - - /// Export a complete bundle using project-selected page and resource paths. - /// Checks ownership, audience visibility and links against the selected layout. - pub fn render_many_with( - documents: &[Self], - audience: Audience, - layout: &crate::ExportLayout, - ) -> Result>> { - crate::render_with(documents, audience, layout) - } - - /// Export this document as a complete bundle with a selected layout. - pub fn render_with( - &self, - audience: Audience, - layout: &crate::ExportLayout, - ) -> Result>> { - Self::render_many_with(std::slice::from_ref(self), audience, layout) - } - - /// Compose an existing host-owned Markdown page without adding a title or - /// block anchors. The host must validate links against its complete bundle. - /// Handles, resources, visibility and binding kinds remain strictly checked. - pub fn render_fragment(&self, audience: Audience) -> Result { - crate::render::render_fragment(self, audience) - } - - /// Copy the frozen resources referenced by this audience, keyed by export path. - /// Fragment adapters must use the same audience for page text and resources. - /// This adds no page or manifest and never reruns a resource producer. - pub fn resource_files(&self, audience: Audience) -> Result>> { - crate::render::resource_files(self, audience) - } -} - -pub(crate) fn valid_id(id: &str) -> bool { - !id.is_empty() - && id.len() <= 100 - && id - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') -} diff --git a/crates/doc-harness/src/paths.rs b/crates/doc-harness/src/paths.rs deleted file mode 100644 index a09fcd1..0000000 --- a/crates/doc-harness/src/paths.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Bundle paths and local URL resolution, independent of Markdown export. -use crate::{ExportLayout, Result, model::Document}; - -pub(crate) fn validate_resource_path(path: &str) -> Result<()> { - if path.is_empty() - || matches!( - path.split('/').next(), - Some("manifest.json" | ".ownership.json") - ) - || path - .chars() - .any(|c| c.is_control() || c.is_whitespace() || "\\:%?#()[]<>\"".contains(c)) - || path - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..") - { - return Err(format!("Unsafe or reserved artifact path: {path:?}")); - } - Ok(()) -} - -pub(crate) fn artifact_path(document: &Document, id: &str) -> String { - ExportLayout::default().resource_path(document, id) -} - -/// Decode URL escapes before checking local ownership, so encoded traversal and -/// encoded audience-hidden resource links receive the same checks as plain ones. -fn decode_path(text: &str) -> Result { - let bytes = text.as_bytes(); - let mut decoded = Vec::new(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - if i + 2 >= bytes.len() { - return Err(format!("Malformed percent escape in link: {text}")); - } - let hex = - std::str::from_utf8(&bytes[i + 1..i + 3]).map_err(|_| "Invalid URL escape")?; - decoded.push( - u8::from_str_radix(hex, 16) - .map_err(|_| format!("Malformed percent escape in link: {text}"))?, - ); - i += 3; - } else { - decoded.push(bytes[i]); - i += 1; - } - } - String::from_utf8(decoded).map_err(|_| "Local link is not UTF-8".into()) -} - -/// Produce a page-relative URL path while keeping ownership paths bundle-relative. -pub(crate) fn relative_path(page: &str, target: &str) -> String { - let mut directory: Vec<_> = page.split('/').collect(); - directory.pop(); - let target: Vec<_> = target.split('/').collect(); - let common = directory - .iter() - .zip(&target) - .take_while(|(a, b)| a == b) - .count(); - std::iter::repeat_n("..", directory.len() - common) - .chain(target[common..].iter().copied()) - .collect::>() - .join("/") -} - -pub(crate) fn local_target(link: &str, page: &str) -> Result)>> { - if link.chars().any(char::is_control) { - return Err("Link contains control characters".into()); - } - for scheme in ["https://", "http://"] { - if let Some(rest) = link.strip_prefix(scheme) { - if rest.is_empty() || rest.starts_with('/') || rest.chars().any(char::is_whitespace) { - return Err(format!("Malformed external link: {link}")); - } - return Ok(None); - } - } - if let Some(rest) = link.strip_prefix("mailto:") { - if rest.is_empty() || rest.chars().any(char::is_whitespace) { - return Err(format!("Malformed mail link: {link}")); - } - return Ok(None); - } - let (path, fragment) = link - .split_once('#') - .map_or((link, None), |(path, fragment)| (path, Some(fragment))); - let path = decode_path(path)?; - if path.contains(['\\', ':', '?']) - || path.starts_with('/') - || (!path.is_empty() && path.split('/').any(str::is_empty)) - || path.chars().any(char::is_control) - { - return Err(format!("Unsafe or unsupported local link: {link}")); - } - let fragment = fragment.map(decode_path).transpose()?; - if path.is_empty() && fragment.as_deref().is_none_or(str::is_empty) { - return Err("An empty link does not identify evidence".into()); - } - let resolved = if path.is_empty() { - page.to_owned() - } else { - let mut components: Vec<_> = page.split('/').collect(); - components.pop(); - for component in path.split('/') { - match component { - "." => {} - ".." => { - if components.pop().is_none() { - return Err(format!("Local link escapes the export bundle: {link}")); - } - } - _ => components.push(component), - } - } - if components.is_empty() { - return Err(format!("Local link does not identify a file: {link}")); - } - components.join("/") - }; - Ok(Some((resolved, fragment))) -} diff --git a/crates/doc-harness/src/prose.rs b/crates/doc-harness/src/prose.rs deleted file mode 100644 index 08eb77e..0000000 --- a/crates/doc-harness/src/prose.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::{Prose, Result, model::Inline}; -use std::collections::{BTreeMap, BTreeSet}; - -impl Prose { - /// Compose named typed parts without flattening their evidence references. - /// Supports `{name}` and escaped `{{`/`}}`; format specifiers are unsupported. - /// Prefer the named form of `prose!` at authoring call sites. - pub fn interpolate(text: &str, bindings: [(&str, Self); N]) -> Result { - let mut names = BTreeMap::new(); - for (name, value) in bindings { - if name.is_empty() || name.chars().any(|c| c.is_whitespace() || "{}:".contains(c)) { - return Err(format!("Invalid prose binding name: {name:?}")); - } - if names.insert(name, value).is_some() { - return Err(format!("Duplicate prose binding: {name}")); - } - } - let mut used = BTreeSet::new(); - let mut parts = Vec::new(); - let mut literal = String::new(); - let mut remaining = text; - while let Some(index) = remaining.find(['{', '}']) { - literal.push_str(&remaining[..index]); - remaining = &remaining[index..]; - if remaining.starts_with("{{") || remaining.starts_with("}}") { - literal.push(remaining.as_bytes()[0] as char); - remaining = &remaining[2..]; - continue; - } - if remaining.starts_with('}') { - return Err("Unmatched closing brace in prose; use }} for a literal brace".into()); - } - let end = remaining.find('}').ok_or("Unclosed prose binding")?; - let name = &remaining[1..end]; - let value = names - .get(name) - .ok_or_else(|| format!("Unknown prose binding: {name:?}"))?; - if !literal.is_empty() { - parts.push(Inline::Text(std::mem::take(&mut literal))); - } - parts.extend(value.0.iter().cloned()); - used.insert(name); - remaining = &remaining[end + 1..]; - } - literal.push_str(remaining); - if !literal.is_empty() { - parts.push(Inline::Text(literal)); - } - let unused: Vec<_> = names.keys().filter(|name| !used.contains(**name)).collect(); - if !unused.is_empty() { - return Err(format!("Unused prose bindings: {unused:?}")); - } - Ok(Self(parts)) - } -} diff --git a/crates/doc-harness/src/render.rs b/crates/doc-harness/src/render.rs deleted file mode 100644 index 2504efa..0000000 --- a/crates/doc-harness/src/render.rs +++ /dev/null @@ -1,403 +0,0 @@ -use crate::model::{BindingValue, Content, Document, Inline, Kind, Node, Prose, Reference}; -use crate::{ - Audience, ExportLayout, Result, - manifest::Manifest, - markdown::{Rendered, escape, markdown_links, parsed_links}, - paths::{artifact_path, local_target, relative_path, validate_resource_path}, - validation::{validate_document, visible_nodes}, -}; -use std::collections::{BTreeMap, BTreeSet}; - -fn node_links(document: &Document, node: &Node, layout: &ExportLayout) -> Result> { - let links = match &node.content { - Content::Markdown(text) => markdown_links(text), - Content::MarkdownParts(parts) => { - parsed_links(&render_prose(document, parts, true, layout), &[]) - } - _ => Ok(Vec::new()), - }; - links.map_err(|error| { - format!( - "Document {} ({}), block {}: {error}", - document.id, - layout.page_path(&document.id), - node.id - ) - }) -} - -fn visible_resources( - document: &Document, - audience: Audience, - layout: &ExportLayout, -) -> Result> { - let paths: BTreeMap<_, _> = document - .artifacts - .keys() - .map(|id| (layout.resource_path(document, id), id)) - .collect(); - let mut used = BTreeSet::new(); - for node in visible_nodes(document, audience) { - for reference in node.references() { - if reference.kind == Kind::Resource { - used.insert(reference.id.clone()); - } - } - for link in node_links(document, node, layout)? { - if let Some((path, _)) = local_target(&link, &layout.page_path(&document.id))? - && let Some(id) = paths.get(&path) - { - used.insert((*id).clone()); - } - } - } - Ok(used) -} - -fn insert_owned_path(paths: &mut BTreeSet, path: String) -> Result<()> { - validate_resource_path(&path)?; - if paths.contains(&path) { - return Err(format!("Duplicate artifact path: {path}")); - } - if paths.iter().any(|existing| { - path.starts_with(&format!("{existing}/")) || existing.starts_with(&format!("{path}/")) - }) { - return Err(format!("Artifact file/directory collision: {path}")); - } - paths.insert(path); - Ok(()) -} - -fn validate_document_with(document: &Document, layout: &ExportLayout) -> Result<()> { - validate_document(document)?; - let mut paths = BTreeSet::from(["manifest.json".to_owned()]); - insert_owned_path(&mut paths, layout.page_path(&document.id))?; - for id in document.artifacts.keys() { - insert_owned_path(&mut paths, layout.resource_path(document, id))?; - } - let used = visible_resources(document, Audience::Contributor, layout)?; - let orphaned: Vec<_> = document - .artifacts - .keys() - .filter(|id| !used.contains(*id)) - .collect(); - if !orphaned.is_empty() { - return Err(format!("Unreferenced resources: {orphaned:?}")); - } - Ok(()) -} - -fn resource_url(document: &Document, reference: &Reference, layout: &ExportLayout) -> String { - if reference.kind == Kind::Block { - return format!("#{}", reference.id); - } - // Encode bytes before Markdown parses entities or punctuation. The stored - // artifact path remains unchanged; ownership checks decode this destination. - relative_path( - &layout.page_path(&document.id), - &layout.resource_path(document, &reference.id), - ) - .bytes() - .map(|byte| { - if byte.is_ascii_alphanumeric() || b"/-._~".contains(&byte) { - (byte as char).to_string() - } else { - format!("%{byte:02X}") - } - }) - .collect() -} - -fn binding_html(tag: &str, text: &str) -> Rendered { - let mut rendered = Rendered::html(format!("<{tag}>")); - rendered.push(Rendered::text(escape_html(text))); - rendered.push(Rendered::html(format!(""))); - rendered -} - -fn inline(document: &Document, reference: &Reference, layout: &ExportLayout) -> Rendered { - Rendered::text(match reference.kind { - Kind::Resource | Kind::Block => format!( - "[{}]({})", - escape(&reference.id), - resource_url(document, reference, layout) - ), - Kind::Claim => format!("`{}`", reference.id), - Kind::Value => { - let actual = &document.checks[&reference.id].actual; - escape( - &actual - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| actual.to_string()), - ) - } - Kind::Binding => match &document.bindings[&reference.id] { - BindingValue::Text(text) => escape(text), - BindingValue::Code(text) => return binding_html("code", text), - BindingValue::Keys(keys) => { - let mut rendered = Rendered::default(); - for (index, key) in keys.iter().enumerate() { - if index != 0 { - rendered.push(Rendered::text(" + ")); - } - rendered.push(binding_html("kbd", key)); - } - return rendered; - } - }, - }) -} - -fn escape_html(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") - .replace('|', "|") - .replace('`', "`") - .replace('*', "*") - .replace('_', "_") - .replace('~', "~") - .replace('\\', "\") - .replace('[', "[") - .replace(']', "]") - .replace('!', "!") -} - -fn render_prose( - document: &Document, - prose: &Prose, - markdown: bool, - layout: &ExportLayout, -) -> Rendered { - let mut rendered = Rendered::default(); - for part in &prose.0 { - rendered.push(match part { - Inline::Text(text) => { - Rendered::text(if markdown { text.clone() } else { escape(text) }) - } - Inline::Reference(reference) => inline(document, reference, layout), - Inline::Image(reference, alt) => Rendered::text(format!( - "![{}]({})", - escape(alt), - resource_url(document, reference, layout) - )), - Inline::Link(reference, label) => Rendered::text(format!( - "[{}]({})", - escape(label), - resource_url(document, reference, layout) - )), - }); - } - rendered -} - -fn code_fence(text: &str) -> String { - let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); - "`".repeat(longest.max(2) + 1) -} - -fn render_page( - document: &Document, - audience: Audience, - fragment: bool, - layout: &ExportLayout, -) -> Result<(String, Vec)> { - let mut page = if fragment { - Rendered::default() - } else { - Rendered::text(format!("# {}\n\n", escape(&document.title))) - }; - let mut boundaries = Vec::new(); - for node in visible_nodes(document, audience) { - boundaries.push((page.text.len(), node.id.as_str())); - if !fragment { - page.push(Rendered::html(format!("", node.id))); - page.push(Rendered::text("\n\n")); - } - let content = match &node.content { - Content::Heading(level, text) => { - Rendered::text(format!("{} {}", "#".repeat((*level).into()), escape(text))) - } - Content::Paragraph(text) => render_prose(document, text, false, layout), - Content::Markdown(text) => Rendered::text(text.clone()), - Content::MarkdownParts(parts) => render_prose(document, parts, true, layout), - Content::Embed(reference, caption) => { - let image = document.artifacts[&reference.id].mime.starts_with("image/"); - Rendered::text(format!( - "{}[{}]({})", - if image { "!" } else { "" }, - escape(caption), - resource_url(document, reference, layout) - )) - } - Content::Code(reference, language) => { - let text = std::str::from_utf8(&document.artifacts[&reference.id].bytes) - .map_err(|_| "Code resource must be UTF-8")?; - let fence = code_fence(text); - Rendered::text(format!( - "{fence}{language}\n{text}{}{fence}", - if text.ends_with('\n') { "" } else { "\n" } - )) - } - Content::Note { target, prose } => { - let mut note = Rendered::text("**Contributor note"); - if let Some(reference) = target { - note.push(Rendered::text(" on ")); - note.push(match reference.kind { - Kind::Claim | Kind::Value => Rendered::text(format!("`{}`", reference.id)), - _ => inline(document, reference, layout), - }); - } - note.push(Rendered::text(":** ")); - note.push(render_prose(document, prose, false, layout)); - note.quote() - } - }; - page.push(content); - if !matches!(node.content, Content::MarkdownParts(_)) { - page.push(Rendered::text("\n\n")); - } - } - let links = parsed_links(&page, &boundaries).map_err(|error| { - format!( - "Document {} ({}): {error}", - document.id, - layout.page_path(&document.id) - ) - })?; - Ok((page.text, links)) -} - -pub(crate) fn render_fragment(document: &Document, audience: Audience) -> Result { - validate_document_with(document, &ExportLayout::default())?; - for node in visible_nodes(document, audience) { - if node - .references() - .iter() - .any(|reference| reference.kind == Kind::Block) - { - return Err( - "Fragment rendering omits block anchors; block references are unsupported".into(), - ); - } - } - render_page(document, audience, true, &ExportLayout::default()).map(|(text, _)| text) -} - -pub(crate) fn resource_files( - document: &Document, - audience: Audience, -) -> Result>> { - validate_document_with(document, &ExportLayout::default())?; - Ok( - visible_resources(document, audience, &ExportLayout::default())? - .into_iter() - .map(|id| { - ( - artifact_path(document, &id), - document.artifacts[&id].bytes.clone(), - ) - }) - .collect(), - ) -} - -/// Render ordinary Markdown, immutable resources and a deterministic JSON manifest. -/// Reader output contains neither contributor blocks nor their exclusive resources. -/// Local link fragments must refer to generated block IDs; raw heading slugs are -/// intentionally not assumed to be identical across downstream site renderers. -pub fn render(documents: &[Document], audience: Audience) -> Result>> { - render_with(documents, audience, &ExportLayout::default()) -} - -/// Render with project-selected bundle paths. Typed links are relative to their -/// containing page; authored links resolve from that same page directory. -/// The complete inventory remains strict across both audiences. -pub fn render_with( - documents: &[Document], - audience: Audience, - layout: &ExportLayout, -) -> Result>> { - layout.validate_documents(documents)?; - if documents.is_empty() { - return Err("At least one document is required".into()); - } - let mut sorted: Vec<_> = documents.iter().collect(); - sorted.sort_by(|a, b| a.id.cmp(&b.id)); - // Ownership is a property of the completed bundle, including contributor-only - // resources. Selecting Reader must not hide a duplicate declaration. - let mut owned_paths = BTreeSet::from(["manifest.json".to_owned()]); - for document in &sorted { - for path in std::iter::once(layout.page_path(&document.id)).chain( - document - .artifacts - .keys() - .map(|id| layout.resource_path(document, id)), - ) { - insert_owned_path(&mut owned_paths, path)?; - } - } - let mut files = BTreeMap::new(); - let mut ids = BTreeSet::new(); - let mut manifest = Manifest::new(audience); - let mut anchors = BTreeMap::new(); - let mut page_links = BTreeMap::new(); - for document in &sorted { - if !ids.insert(&document.id) { - return Err(format!("Duplicate document ID: {}", document.id)); - } - validate_document_with(document, layout)?; - let path = layout.page_path(&document.id); - let (page, links) = render_page(document, audience, false, layout)?; - let page = page.into_bytes(); - page_links.insert(&document.id, links); - manifest.add_page(&document.id, &path, &page); - if files.insert(path.clone(), page).is_some() { - return Err(format!("Duplicate artifact path: {path}")); - } - anchors.insert( - path, - visible_nodes(document, audience) - .map(|node| node.id.clone()) - .collect::>(), - ); - let resources = visible_resources(document, audience, layout)?; - for id in resources { - let artifact = &document.artifacts[&id]; - let path = layout.resource_path(document, &id); - manifest.add_resource(&document.id, &id, &path, artifact); - if files.insert(path.clone(), artifact.bytes.clone()).is_some() { - return Err(format!("Duplicate artifact path: {path}")); - } - } - manifest.add_document(document, audience)?; - } - // Validate against the actual filtered bundle, never against all collected resources. - for document in &sorted { - for link in &page_links[&document.id] { - if let Some((path, fragment)) = local_target(link, &layout.page_path(&document.id))? { - if !files.contains_key(&path) { - return Err(format!( - "Broken or excluded local link in {}: {link}", - document.id - )); - } - if let Some(fragment) = fragment - && !anchors - .get(&path) - .is_some_and(|ids| ids.contains(&fragment)) - { - return Err(format!( - "Unknown generated block anchor in {}: {link}", - document.id - )); - } - } - } - } - files.insert("manifest.json".into(), manifest.encode()?); - Ok(files) -} diff --git a/crates/doc-harness/src/runner.rs b/crates/doc-harness/src/runner.rs deleted file mode 100644 index e2fa3e5..0000000 --- a/crates/doc-harness/src/runner.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! Small project-owned runner: execute once, render before publishing, then verify. -use crate::{Audience, Document, ExportLayout, Result, lifecycle, render_with, store}; -use std::{ - ffi::OsString, - path::{Path, PathBuf}, -}; - -const USAGE: &str = "Usage: check|update|build --out PATH [--audience reader|contributor|both]"; - -/// A project may call `run` directly without adopting the supplied CLI syntax. -#[derive(Clone, Debug)] -pub enum RunMode { - Check, - Update, - Build(PathBuf), -} - -struct Options { - mode: RunMode, - audiences: Option>, -} - -#[derive(Clone, Debug)] -struct Output { - audience: Audience, - baseline: PathBuf, - candidate_dir: PathBuf, -} - -/// Caller-owned filesystem and export policy. No repository or Cargo discovery. -/// Relative baseline and build paths resolve against `base_dir`, captured once. -#[derive(Clone, Debug)] -pub struct RunnerConfig { - base_dir: PathBuf, - outputs: Vec, - layout: ExportLayout, - defaults: Option>, - protected_roots: Vec, -} - -impl RunnerConfig { - pub fn new(base_dir: impl AsRef) -> Result { - Ok(Self { - base_dir: lifecycle::absolute_base(base_dir.as_ref())?, - outputs: Vec::new(), - layout: ExportLayout::default(), - defaults: None, - protected_roots: Vec::new(), - }) - } - - /// Configure one retained tree and its relative directory under a build root. - /// An empty candidate directory puts a single selected audience at that root. - pub fn output( - mut self, - audience: Audience, - baseline: impl Into, - candidate_dir: impl Into, - ) -> Result { - if self - .outputs - .iter() - .any(|output| output.audience == audience) - { - return Err(format!( - "Duplicate configured audience: {}", - audience_name(audience) - )); - } - let candidate_dir = candidate_dir.into(); - if !candidate_dir.as_os_str().is_empty() { - lifecycle::validate_path( - candidate_dir - .to_str() - .ok_or("Candidate directory must be UTF-8")?, - )?; - } - self.outputs.push(Output { - audience, - baseline: baseline.into(), - candidate_dir, - }); - Ok(self) - } - - pub fn layout(mut self, layout: ExportLayout) -> Self { - self.layout = layout; - self - } - - /// CLI default selection. Without this override, all configured outputs run. - pub fn default_audiences(mut self, audiences: impl IntoIterator) -> Self { - self.defaults = Some(audiences.into_iter().collect()); - self - } - - pub fn base_dir(&self) -> &Path { - &self.base_dir - } - - fn resolve(&self, path: &Path) -> Result { - store::absolute(&self.base_dir.join(path)) - } -} - -fn parse(args: impl IntoIterator) -> Result { - let mut args = args.into_iter(); - let mode = args.next().ok_or(USAGE)?; - if !matches!(mode.as_str(), "check" | "update" | "build") { - return Err(USAGE.into()); - } - let mut out = None; - let mut selected = None; - while let Some(argument) = args.next() { - match argument.as_str() { - "--out" if out.is_none() => { - let value = args.next().ok_or("--out requires a directory")?; - if value.is_empty() || value.starts_with("--") { - return Err("--out requires a directory".into()); - } - out = Some(PathBuf::from(value)); - } - "--audience" if selected.is_none() => { - selected = Some(match args.next().as_deref() { - Some("reader") => vec![Audience::Reader], - Some("contributor") => vec![Audience::Contributor], - Some("both") => vec![Audience::Reader, Audience::Contributor], - _ => return Err("--audience requires reader, contributor, or both".into()), - }); - } - _ => { - return Err(format!( - "Unknown or duplicate argument {argument:?}. {USAGE}" - )); - } - } - } - let mode = match (mode.as_str(), out) { - ("check", None) => RunMode::Check, - ("update", None) => RunMode::Update, - ("build", Some(out)) => RunMode::Build(out), - ("build", None) => return Err("build requires --out PATH".into()), - _ => return Err("--out is only valid for build".into()), - }; - Ok(Options { - mode, - audiences: selected, - }) -} - -fn audience_name(audience: Audience) -> &'static str { - match audience { - Audience::Reader => "reader", - Audience::Contributor => "contributor", - } -} - -fn existing_ancestor(path: &Path) -> Result<(PathBuf, Vec)> { - let mut ancestor = path; - let mut missing = Vec::new(); - while !ancestor.try_exists().map_err(|error| error.to_string())? { - missing.push( - ancestor - .file_name() - .ok_or("Output path has no existing ancestor")? - .to_owned(), - ); - ancestor = ancestor.parent().ok_or("Output path has no parent")?; - } - missing.reverse(); - Ok((ancestor.to_owned(), missing)) -} - -// Missing destinations have no filesystem identity yet. A fresh Contributor -// path must not alias a Reader path after its first publication. Existing -// prefixes establish real separation; otherwise require one pair of missing -// components to be distinct ASCII names even on a case-insensitive filesystem. -// Do not guess Unicode normalization/case equivalence: an ASCII distinguishing -// component or preexisting distinct directories keeps those paths usable. Names -// with trailing dots/spaces or DOS-short-name syntax are ambiguous too. -fn ensure_distinct_destinations(left: &Path, right: &Path) -> Result<()> { - // A candidate at `.manual.backup` can disable checks of `manual`, and an - // audience inside another audience's staging tree can collide during a later - // update. Reserve the complete transaction namespace before any generation. - ensure_distinct_paths(left, right)?; - for reserved in lifecycle::transaction_paths(left)?.into_iter().skip(1) { - ensure_distinct_paths(&reserved, right)?; - } - for reserved in lifecycle::transaction_paths(right)?.into_iter().skip(1) { - ensure_distinct_paths(left, &reserved)?; - } - Ok(()) -} - -fn ensure_distinct_paths(left: &Path, right: &Path) -> Result<()> { - lifecycle::ensure_separate_paths(left, right)?; - let (left_parent, left_missing) = existing_ancestor(left)?; - let (right_parent, right_missing) = existing_ancestor(right)?; - if !same_file::is_same_file(left_parent, right_parent).map_err(|error| error.to_string())? { - return Ok(()); - } - if left_missing - .iter() - .zip(&right_missing) - .any(|(left, right)| match (left.to_str(), right.to_str()) { - (Some(left), Some(right)) - if [left, right].into_iter().all(|component| { - component.is_ascii() - && !component.ends_with(['.', ' ']) - && !component.contains('~') - }) => - { - !left.eq_ignore_ascii_case(right) - } - _ => false, - }) - { - return Ok(()); - } - Err(format!( - "Output paths may overlap through filesystem aliases: {} and {}; use a distinct portable ASCII directory component or precreate distinct directories", - left.display(), - right.display() - )) -} - -/// Convenience wrapper retaining the original reader/contributor directory layout -/// and reader-only default. Arguments exclude the executable name. -pub fn run_cli( - args: impl IntoIterator, - baseline: &Path, - generate: impl FnOnce() -> Result>, -) -> Result<()> { - let mut config = RunnerConfig::new(std::env::current_dir().map_err(|e| e.to_string())?)? - .output(Audience::Reader, baseline.join("reader"), "reader")? - .output( - Audience::Contributor, - baseline.join("contributor"), - "contributor", - )? - .default_audiences([Audience::Reader]); - // The convenience API historically reserves its entire baseline root. - // Explicit configurations reserve their individually configured trees. - config.protected_roots.push(baseline.to_owned()); - run_cli_with(args, &config, generate) -} - -/// Use caller-owned paths/layout with the optional standard CLI adapter. -pub fn run_cli_with( - args: impl IntoIterator, - config: &RunnerConfig, - generate: impl FnOnce() -> Result>, -) -> Result<()> { - let options = parse(args)?; - let audiences = options.audiences.unwrap_or_else(|| { - config.defaults.clone().unwrap_or_else(|| { - config - .outputs - .iter() - .map(|output| output.audience) - .collect() - }) - }); - run(config, options.mode, &audiences, generate) -} - -/// Execute once and prepare every selected export before publishing anything. -/// Destinations are validated before generation. Each audience has an independent -/// transaction; a multi-audience update is not atomic. -pub fn run( - config: &RunnerConfig, - mode: RunMode, - audiences: &[Audience], - generate: impl FnOnce() -> Result>, -) -> Result<()> { - if audiences.is_empty() { - return Err("Select at least one configured audience".into()); - } - let baselines = config - .outputs - .iter() - .map(|output| config.resolve(&output.baseline)) - .collect::>>()?; - for (index, path) in baselines.iter().enumerate() { - for other in &baselines[..index] { - ensure_distinct_destinations(path, other)?; - } - } - let build_root = if let RunMode::Build(path) = &mode { - let output = config.resolve(path)?; - for baseline in &baselines { - ensure_distinct_destinations(&output, baseline)?; - } - for protected in &config.protected_roots { - ensure_distinct_destinations(&output, &config.resolve(protected)?)?; - } - if output.try_exists().map_err(|e| e.to_string())? { - return Err(format!("Build root already exists: {}", output.display())); - } - Some(output) - } else { - None - }; - let mut destinations: Vec<(Audience, PathBuf)> = Vec::new(); - for &audience in audiences { - if destinations - .iter() - .any(|(selected, _)| *selected == audience) - { - return Err(format!( - "Duplicate selected audience: {}", - audience_name(audience) - )); - } - let index = config - .outputs - .iter() - .position(|output| output.audience == audience) - .ok_or_else(|| format!("Audience is not configured: {}", audience_name(audience)))?; - let path = match &build_root { - Some(root) => store::absolute(&root.join(&config.outputs[index].candidate_dir))?, - None => baselines[index].clone(), - }; - for (_, other) in &destinations { - ensure_distinct_destinations(&path, other)?; - } - destinations.push((audience, path)); - } - let prepared = lifecycle::prepare(|| { - let documents = generate()?; - let mut prepared = Vec::new(); - for (audience, destination) in destinations { - let name = audience_name(audience); - let files = render_with(&documents, audience, &config.layout) - .map_err(|error| format!("Render {name}: {error}"))?; - prepared.push((name, destination, files)); - } - Ok::<_, String>(prepared) - })?; - - let mut completed = Vec::new(); - for (name, destination, files) in prepared { - let result = match mode { - RunMode::Check => store::check(&destination, &files), - RunMode::Build(_) => store::build(&destination, &files), - RunMode::Update => store::update(&destination, &files), - }; - if let Err(error) = result { - let previous = if completed.is_empty() { - String::new() - } else { - format!( - "; earlier audiences completed independently: {}", - completed.join(", ") - ) - }; - return Err(format!("{name}: {error}{previous}")); - } - completed.push(name); - } - Ok(()) -} diff --git a/crates/doc-harness/src/store.rs b/crates/doc-harness/src/store.rs deleted file mode 100644 index 0ec7272..0000000 --- a/crates/doc-harness/src/store.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Structured SDK manifest policy over the shared artifact lifecycle. -use crate::{ - Result, - lifecycle::{self, Ownership}, - manifest::{Manifest, validate_tree}, -}; -pub use lifecycle::Files; -use std::{ - collections::BTreeSet, - path::{Path, PathBuf}, -}; - -pub(crate) fn absolute(path: &Path) -> Result { - lifecycle::absolute(path) -} - -fn compatible(baseline: &Files, candidate: &Files) -> Result<()> { - validate_tree(baseline, None)?; - compatible_profiles(baseline, candidate) -} - -fn compatible_update( - baseline: &Files, - candidate: &Files, - missing_owned: &BTreeSet, -) -> Result<()> { - // Explicit retirement removes an old file before update. Its old receipt is - // necessarily stale until this transaction publishes the complete new tree. - // Only a missing old artifact absent from the candidate qualifies; changed - // retained bytes, unrecorded files and missing retained files still fail. - validate_tree(baseline, Some(candidate))?; - // A removed file is intentional retirement only when both retained records - // identify it. Phantom ownership entries must never be silently rewritten; - // nor may retirement conceal omissions from the ownership marker. - let old = Manifest::read(baseline)?; - let mut retired = BTreeSet::new(); - for artifact in &old.artifacts { - let path = artifact.path(); - if !baseline.contains_key(path) { - retired.insert(path.to_owned()); - } - } - if &retired != missing_owned { - return Err(format!( - "Ownership inventory differs from retained manifest: missing owned {missing_owned:?}; retired artifacts {retired:?}" - )); - } - compatible_profiles(baseline, candidate) -} - -fn compatible_profiles(baseline: &Files, candidate: &Files) -> Result<()> { - let old = Manifest::read(baseline)?; - let new = Manifest::read(candidate)?; - if old.audience != new.audience { - return Err(format!( - "Incompatible audience: baseline={:?}, candidate={:?}; use a separately reviewed baseline", - old.audience, new.audience - )); - } - // Compatibility belongs to retained resource identities, not the aggregate - // profile set: new features may introduce a producer, and two retained - // resources can swap profiles without changing that aggregate set. - let previous = old.resource_profiles()?; - let next = new.resource_profiles()?; - for (identity, profile) in previous { - if let Some(candidate) = next.get(&identity) - && *candidate != profile - { - return Err(format!( - "Incompatible producer/comparison profiles for {}/{}: baseline={profile:?}, candidate={candidate:?}; use a separately reviewed baseline", - identity.0, identity.1, - )); - } - } - Ok(()) -} - -fn validate(files: &Files) -> Result<()> { - validate_tree(files, None) -} - -/// Read-only exact check with structured manifest compatibility. -pub fn check(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::check(path, files, Ownership::Recorded, compatible) -} - -/// Generate a new, independently owned candidate directory. -pub fn build(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::build(path, files, Ownership::Recorded) -} - -/// Replace a compatible owned baseline only after staging and validation. -pub fn update(path: &Path, files: &Files) -> Result<()> { - validate(files)?; - lifecycle::update_with_retirement(path, files, compatible_update) -} - -/// Explicitly restore a complete valid backup when its destination is absent. -pub fn recover_backup(path: &Path) -> Result<()> { - lifecycle::recover_backup(path, Ownership::Recorded, validate) -} diff --git a/crates/doc-harness/src/validation.rs b/crates/doc-harness/src/validation.rs deleted file mode 100644 index c222479..0000000 --- a/crates/doc-harness/src/validation.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Intrinsic document visibility, independent of output formatting. -use crate::{ - Audience, Result, - model::{Document, Kind, Node}, -}; -use std::collections::BTreeSet; - -pub(crate) fn visible_nodes( - document: &Document, - audience: Audience, -) -> impl Iterator { - document - .nodes - .iter() - .filter(move |node| audience == Audience::Contributor || !node.is_note()) -} - -pub(crate) fn validate_document(document: &Document) -> Result<()> { - for audience in [Audience::Reader, Audience::Contributor] { - let ids: BTreeSet<_> = visible_nodes(document, audience) - .map(|node| node.id.as_str()) - .collect(); - for node in visible_nodes(document, audience) { - for reference in node.references() { - if reference.kind == Kind::Block && !ids.contains(reference.id.as_str()) { - return Err(format!( - "Visible block {} references excluded block {}", - node.id, reference.id - )); - } - } - } - } - Ok(()) -} diff --git a/crates/doc-harness/tests/acceptance.rs b/crates/doc-harness/tests/acceptance.rs deleted file mode 100644 index 4a72629..0000000 --- a/crates/doc-harness/tests/acceptance.rs +++ /dev/null @@ -1,186 +0,0 @@ -use executable_docs::{Artifact, Audience, Doc, Document}; -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicUsize, Ordering}; - -fn resource(text: &str) -> Artifact { - Artifact::new( - text.as_bytes(), - "text/plain", - "txt", - "acceptance", - "utf8-v1", - ) - .unwrap() -} - -fn all_text(files: &BTreeMap>) -> String { - files - .iter() - .map(|(path, bytes)| format!("{path}\n{}", String::from_utf8_lossy(bytes))) - .collect() -} - -fn authored_document(notes: bool) -> Document { - let mut doc = Doc::new("configuration", "Configuration").unwrap(); - let port = doc.expect_eq("port-preserved", 8080, 8080).unwrap(); - doc.heading(2, "Choose a port").unwrap(); - if notes { - doc.note("INTERNAL NOTE: explain the fixed fixture.") - .unwrap(); - } - let paragraph = doc.paragraph(("The port is ", &port, ".")).unwrap(); - let output = doc.resource("settings", resource("port=8080")).unwrap(); - doc.code(&output, "text").unwrap(); - doc.paragraph(("Download ", &output, " to reuse it.")) - .unwrap(); - if notes { - let private = doc - .resource( - "private-observation", - Artifact::new( - b"PRIVATE RESOURCE BODY", - "application/x-private-review", - "bin", - "private-review-producer", - "private-review-profile", - ) - .unwrap(), - ) - .unwrap(); - doc.note_on(¶graph, ("Check this paragraph against ", &private, ".")) - .unwrap(); - doc.note_on(&output, "INTERNAL CAPTURE NOTE").unwrap(); - } - doc.finish().unwrap() -} - -#[test] -fn notes_do_not_change_any_reader_byte_or_resource_identity() { - let without = Document::render_many(&[authored_document(false)], Audience::Reader).unwrap(); - let with = Document::render_many(&[authored_document(true)], Audience::Reader).unwrap(); - assert_eq!( - without, with, - "Audience filtering must precede stable public identities" - ); - let reader = all_text(&with); - for private in [ - "INTERNAL", - "PRIVATE RESOURCE", - "private-observation", - "private-review", - "x-private-review", - ] { - assert!(!reader.contains(private), "Reader leaked {private}"); - } - assert_eq!(with.keys().filter(|path| path.ends_with(".txt")).count(), 1); - - let contributor = - Document::render_many(&[authored_document(true)], Audience::Contributor).unwrap(); - let contributor_text = all_text(&contributor); - assert!(contributor_text.contains("INTERNAL CAPTURE NOTE")); - assert!(contributor_text.contains("PRIVATE RESOURCE BODY")); -} - -#[test] -fn private_value_payload_is_not_exposed_through_reader_manifest() { - let mut doc = Doc::new("private-value", "Notes").unwrap(); - doc.require("public-condition", true).unwrap(); - doc.paragraph("The configuration is valid.").unwrap(); - let private = doc - .expect_eq("diagnostic", "INTERNAL-TOKEN", "INTERNAL-TOKEN") - .unwrap(); - doc.note(("Internal observation: ", &private)).unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - assert!(!all_text(&reader).contains("INTERNAL-TOKEN")); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - assert!(all_text(&contributor).contains("INTERNAL-TOKEN")); -} - -#[test] -fn annotating_an_existing_check_does_not_change_reader_manifest() { - let build = |with_note| { - let mut doc = Doc::new("checks", "Checks").unwrap(); - let claim = doc.require("preserves-output", true).unwrap(); - doc.paragraph("Invalid input preserves the original output.") - .unwrap(); - if with_note { - doc.note_on( - &claim, - "Compare complete bytes rather than only the parsed port.", - ) - .unwrap(); - } - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() - }; - assert_eq!(build(false), build(true)); -} - -#[test] -fn rendering_freezes_resources_and_never_reexecutes_application() { - let executions = AtomicUsize::new(0); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("observation.txt"); - std::fs::write(&path, "first observation").unwrap(); - let document = { - executions.fetch_add(1, Ordering::SeqCst); - let mut doc = Doc::new("frozen", "Frozen evidence").unwrap(); - let mut value = String::from("first observation"); - let checked = doc.expect_eq("observed", &value, &value).unwrap(); - doc.paragraph(("Observed: ", &checked)).unwrap(); - let artifact = - Artifact::from_file(&path, "text/plain", "txt", "fixture", "utf8-v1").unwrap(); - let frozen = doc.resource("file", artifact).unwrap(); - doc.code(&frozen, "text").unwrap(); - value.clear(); - std::fs::write(&path, "changed after capture").unwrap(); - doc.finish().unwrap() - }; - for audience in [Audience::Reader, Audience::Contributor, Audience::Reader] { - let files = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - assert!(all_text(&files).contains("first observation")); - assert!(!all_text(&files).contains("changed after capture")); - } - assert_eq!(executions.load(Ordering::SeqCst), 1); -} - -#[test] -fn a_foreign_note_target_cannot_be_smuggled_into_reader_only_rendering() { - let mut first = Doc::new("first", "First").unwrap(); - let claim = first.require("valid", true).unwrap(); - first.paragraph("First document.").unwrap(); - first.finish().unwrap(); - - let mut second = Doc::new("second", "Second").unwrap(); - second.require("valid", true).unwrap(); - second.paragraph("Second document.").unwrap(); - assert!(second.note_on(&claim, "Foreign target").is_err()); - assert!( - second.finish().is_err(), - "Ignoring a note error cannot bless the candidate" - ); -} - -#[test] -fn duplicate_document_identity_fails_the_complete_bundle() { - let document = authored_document(false); - let result = Document::render_many(&[document.clone(), document], Audience::Reader); - assert!(result.is_err()); -} - -#[test] -fn code_fences_preserve_backticks_without_manufacturing_links() { - let mut doc = Doc::new("fences", "Literal output").unwrap(); - doc.require("command-produced-text", true).unwrap(); - let text = "```\n[not a link](missing.txt)\n```\n"; - let artifact = doc.resource("transcript", resource(text)).unwrap(); - doc.code(&artifact, "text").unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - let markdown = String::from_utf8_lossy(&files["fences.md"]); - let parser = pulldown_cmark::Parser::new(&markdown); - assert!(!parser.into_iter().any(|event| matches!( - event, - pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { .. }) - ))); - assert!(markdown.contains(text)); -} diff --git a/crates/doc-harness/tests/json_review.rs b/crates/doc-harness/tests/json_review.rs deleted file mode 100644 index 68efe70..0000000 --- a/crates/doc-harness/tests/json_review.rs +++ /dev/null @@ -1,65 +0,0 @@ -use executable_docs::{Audience, Doc, Document}; -use serde_json::{Map, Value, json}; -use std::collections::{BTreeMap, HashMap}; - -fn ordered_object(reverse: bool) -> Value { - let mut inner = Map::new(); - for key in if reverse { ["z", "a"] } else { ["a", "z"] } { - inner.insert(key.into(), json!(key)); - } - let mut outer = Map::new(); - for key in if reverse { ["z", "a"] } else { ["a", "z"] } { - outer.insert(key.into(), json!([inner.clone(), 2, 1])); - } - Value::Object(outer) -} - -fn export(value: Value) -> BTreeMap> { - let mut doc = Doc::new("json", "JSON").unwrap(); - let checked = doc - .expect_eq("value", value.clone(), ordered_object(false)) - .unwrap(); - assert_eq!( - checked.observed().to_string(), - r#"{"a":[{"a":"a","z":"z"},2,1],"z":[{"a":"a","z":"z"},2,1]}"#, - ); - doc.paragraph(("Observed: ", &checked)).unwrap(); - doc.json_code("result", &value).unwrap(); - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn nested_values_are_canonical_in_resources_prose_and_manifest() { - // Also run this test in the independent consumer with preserve_order enabled. - // Arrays deliberately contain descending elements: their order must survive. - let files = export(ordered_object(true)); - assert_eq!(files, export(ordered_object(false))); - let bytes = &files["assets/json/result.json"]; - assert_eq!( - std::str::from_utf8(bytes).unwrap(), - "{\n \"a\": [\n {\n \"a\": \"a\",\n \"z\": \"z\"\n },\n 2,\n 1\n ],\n \"z\": [\n {\n \"a\": \"a\",\n \"z\": \"z\"\n },\n 2,\n 1\n ]\n}\n", - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - assert_eq!( - manifest["documents"][0]["checks"][0]["actual"], - ordered_object(false) - ); - assert_eq!( - manifest["documents"][0]["checks"][0]["expected"], - ordered_object(false) - ); -} - -#[test] -fn hash_maps_are_sorted_recursively_without_reordering_arrays() { - let nested = HashMap::from([("z", 3), ("a", 1), ("m", 2)]); - let input = HashMap::from([("z", vec![nested.clone()]), ("a", vec![nested])]); - let mut doc = Doc::new("hash-map", "Hash map").unwrap(); - doc.require("executed", true).unwrap(); - doc.json_code("result", &input).unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - assert_eq!( - std::str::from_utf8(&files["assets/hash-map/result.json"]).unwrap(), - "{\n \"a\": [\n {\n \"a\": 1,\n \"m\": 2,\n \"z\": 3\n }\n ],\n \"z\": [\n {\n \"a\": 1,\n \"m\": 2,\n \"z\": 3\n }\n ]\n}\n", - ); -} diff --git a/crates/doc-harness/tests/layout.rs b/crates/doc-harness/tests/layout.rs deleted file mode 100644 index 2e81160..0000000 --- a/crates/doc-harness/tests/layout.rs +++ /dev/null @@ -1,375 +0,0 @@ -use executable_docs::{Artifact, Audience, Doc, Document, ExportLayout}; -use pulldown_cmark::{Event, Parser, Tag}; -use serde_json::Value; - -fn doc(id: &str) -> Doc { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - doc -} - -fn text() -> Artifact { - Artifact::new(b"frozen evidence", "text/plain", "txt", "host", "v1").unwrap() -} - -#[test] -fn default_layout_preserves_all_export_bytes_and_freezes_no_repository_paths() { - let mut doc = doc("guide"); - let resource = doc.text("result", "actual result").unwrap(); - doc.paragraph(resource.link("Result")).unwrap(); - doc.note("Private rationale").unwrap(); - let document = doc.finish().unwrap(); - for audience in [Audience::Reader, Audience::Contributor] { - let ordinary = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - assert_eq!( - ordinary, - document - .render_with(audience, &ExportLayout::default()) - .unwrap() - ); - assert_eq!( - ordinary, - executable_docs::render_with( - std::slice::from_ref(&document), - audience, - &ExportLayout::default() - ) - .unwrap() - ); - assert_eq!(ordinary["assets/guide/result.txt"], b"actual result"); - } - let root_resources = ExportLayout::default().resources_under("").unwrap(); - let files = document - .render_with(Audience::Reader, &root_resources) - .unwrap(); - assert_eq!(files["guide/result.txt"], b"actual result"); - assert!(!files.contains_key("assets/guide/result.txt")); -} - -#[test] -fn nested_pages_use_relative_encoded_urls_for_typed_resources_and_keep_explicit_paths() { - let mut doc = doc("start"); - let automatic = doc.text("result", "actual result").unwrap(); - let explicit = doc - .resource_at("explicit", text(), "downloads/a©-é.txt") - .unwrap(); - doc.paragraph(( - automatic.link("Automatic"), - " and ", - explicit.link("Explicit"), - )) - .unwrap(); - doc.embed(&explicit, "Download").unwrap(); - let document = doc.finish().unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap() - .resources_under("evidence/frozen") - .unwrap() - .page("start", "manual/入口/index.md") - .unwrap(); - let files = document.render_with(Audience::Reader, &layout).unwrap(); - assert!(files.contains_key("manual/入口/index.md")); - assert_eq!(files["downloads/a©-é.txt"], b"frozen evidence"); - assert_eq!(files["evidence/frozen/start/result.txt"], b"actual result"); - let page = std::str::from_utf8(&files["manual/入口/index.md"]).unwrap(); - let links: Vec<_> = Parser::new(page) - .filter_map(|event| match event { - Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url.into_string()), - _ => None, - }) - .collect(); - assert_eq!( - links, - [ - "../../evidence/frozen/start/result.txt", - "../../downloads/a%26copy%3B-%C3%A9.txt", - "../../downloads/a%26copy%3B-%C3%A9.txt" - ] - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let page_record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["kind"] == "page") - .unwrap(); - assert_eq!(page_record["path"], "manual/入口/index.md"); -} - -#[test] -fn authored_relative_links_resolve_from_selected_page_and_validate_generated_anchors() { - let mut first = doc("first"); - first - .markdown("[Second](../reference/second.md#details)") - .unwrap(); - let mut second = doc("second"); - second.heading_with_id("details", 2, "Details").unwrap(); - second - .resource_at("result", text(), "downloads/data.txt") - .unwrap(); - second.markdown("[Data](../../downloads/data.txt)").unwrap(); - let documents = [first.finish().unwrap(), second.finish().unwrap()]; - let layout = ExportLayout::default() - .page("first", "manual/start/index.md") - .unwrap() - .page("second", "manual/reference/second.md") - .unwrap(); - let files = Document::render_many_with(&documents, Audience::Reader, &layout).unwrap(); - assert_eq!(files["downloads/data.txt"], b"frozen evidence"); - assert!(Document::render_many(&documents, Audience::Reader).is_err()); - let changed_layout = ExportLayout::default() - .page("first", "manual/start/index.md") - .unwrap() - .page("second", "manual/reference/moved.md") - .unwrap(); - assert!( - Document::render_many_with(&documents, Audience::Reader, &changed_layout) - .unwrap_err() - .contains("Broken") - ); -} - -#[test] -fn selected_layout_rejects_parent_traversal_outside_bundle_including_encoded_paths() { - let layout = ExportLayout::default().pages_under("manual").unwrap(); - for link in [ - "../../secret.txt", - "%2e%2e/%2e%2e/secret.txt", - "../%2e%2e/secret.txt", - "/secret.txt", - "https:///missing", - ] { - let mut doc = doc("guide"); - doc.markdown(&format!("[Bad]({link})")).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document.render_with(Audience::Reader, &layout).is_err(), - "Accepted {link}" - ); - } -} - -#[test] -fn layout_validation_rejects_unsafe_and_unknown_configuration() { - for path in [ - "/absolute", - "../outside", - "a/../outside", - "a//b", - "a/./b", - "a\\b", - "manifest.json", - "manifest.json/guide.md", - ".ownership.json", - ".ownership.json/file.txt", - "a?b", - "a#b", - "a%2Fb", - ] { - assert!( - ExportLayout::default().pages_under(path).is_err(), - "pages {path}" - ); - assert!( - ExportLayout::default().resources_under(path).is_err(), - "resources {path}" - ); - assert!( - ExportLayout::default().page("guide", path).is_err(), - "page {path}" - ); - } - assert!(ExportLayout::default().page("guide", "").is_err()); - assert!(ExportLayout::default().page("bad/id", "guide.md").is_err()); - assert!( - ExportLayout::default() - .page("guide", "one.md") - .unwrap() - .page("guide", "two.md") - .is_err() - ); - let layout = ExportLayout::default().page("missing", "one.md").unwrap(); - let document = doc("guide").finish().unwrap(); - assert!( - document - .render_with(Audience::Reader, &layout) - .unwrap_err() - .contains("unknown document") - ); -} - -#[test] -fn layout_collisions_include_hidden_resources_and_file_directory_conflicts() { - let mut first = doc("first"); - let private = first - .resource_at("private", text(), "manual/second.md") - .unwrap(); - first.note((&private, ": private evidence")).unwrap(); - let second = doc("second").finish().unwrap(); - let layout = ExportLayout::default().pages_under("manual").unwrap(); - assert!( - Document::render_many_with( - &[first.finish().unwrap(), second], - Audience::Reader, - &layout - ) - .unwrap_err() - .contains("Duplicate artifact path") - ); - - let mut guide = doc("guide"); - let resource = guide.resource_at("file", text(), "manual").unwrap(); - guide.paragraph(&resource).unwrap(); - assert!( - guide - .finish() - .unwrap() - .render_with(Audience::Reader, &layout) - .unwrap_err() - .contains("file/directory collision") - ); -} - -#[test] -fn nested_layout_keeps_private_resources_and_metadata_out_of_reader_bundle() { - let mut doc = doc("guide"); - doc.paragraph("Public text").unwrap(); - let resource = doc - .resource_at("secret", text(), "private/secret.txt") - .unwrap(); - doc.note(("Private attachment: ", &resource)).unwrap(); - let document = doc.finish().unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap() - .resources_under("evidence") - .unwrap(); - let reader = document.render_with(Audience::Reader, &layout).unwrap(); - let contributor = document - .render_with(Audience::Contributor, &layout) - .unwrap(); - assert!(!reader.contains_key("private/secret.txt")); - assert!(contributor.contains_key("private/secret.txt")); - assert!(!String::from_utf8_lossy(&reader["manifest.json"]).contains("secret")); - assert!( - String::from_utf8_lossy(&contributor["manual/chapters/guide.md"]) - .contains("../../private/secret.txt") - ); -} - -#[test] -fn layout_dependent_orphans_fail_before_any_export_returns() { - let mut doc = doc("guide"); - doc.text("unused", "unused bytes").unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("Unreferenced") - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap_err() - .contains("Unreferenced") - ); - assert!( - document - .render_with( - Audience::Reader, - &ExportLayout::default().pages_under("manual").unwrap() - ) - .unwrap_err() - .contains("Unreferenced") - ); -} - -#[test] -fn explicit_resource_paths_do_not_conflict_with_unselected_default_paths() { - let mut doc = doc("guide"); - let automatic = doc.text("first", "automatic").unwrap(); - let explicit = doc - .resource_at("second", text(), "assets/guide/first.txt") - .unwrap(); - doc.paragraph((&automatic, " and ", &explicit)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_with(Audience::Reader, &ExportLayout::default()) - .unwrap_err() - .contains("Duplicate artifact path") - ); - let layout = ExportLayout::default().resources_under("evidence").unwrap(); - let files = document.render_with(Audience::Reader, &layout).unwrap(); - assert_eq!(files["evidence/guide/first.txt"], b"automatic"); - assert_eq!(files["assets/guide/first.txt"], b"frozen evidence"); -} - -#[test] -fn nested_cross_document_links_cannot_reference_excluded_notes_or_resources() { - for use_note in [true, false] { - let mut second = doc("second"); - let secret = second - .resource_at("secret", text(), "private/secret.txt") - .unwrap(); - let note = second.note((&secret, ": private evidence")).unwrap(); - let target = if use_note { - format!("second.md#{}", note.id()) - } else { - "../../private/secret.txt".into() - }; - let mut first = doc("first"); - first.markdown(&format!("[Excluded]({target})")).unwrap(); - let layout = ExportLayout::default() - .pages_under("manual/chapters") - .unwrap(); - let documents = [first.finish().unwrap(), second.finish().unwrap()]; - assert!(Document::render_many_with(&documents, Audience::Reader, &layout).is_err()); - assert!(Document::render_many_with(&documents, Audience::Contributor, &layout).is_ok()); - } -} - -#[test] -fn fragments_and_resource_exports_reject_impossible_file_directory_inventories() { - let mut doc = doc("guide"); - let parent = doc.resource_at("parent", text(), "downloads").unwrap(); - let child = doc - .resource_at("child", text(), "downloads/file.txt") - .unwrap(); - doc.paragraph((&parent, " and ", &child)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("file/directory collision") - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap_err() - .contains("file/directory collision") - ); -} - -#[test] -fn reserved_ownership_metadata_cannot_be_registered_as_contributor_only_evidence() { - for path in [ - ".ownership.json", - ".ownership.json/private.txt", - "manifest.json/private.txt", - ] { - let mut document = doc("guide"); - document.paragraph("Reader narrative").unwrap(); - let result = document - .resource_at("private", text(), path) - .and_then(|resource| document.note(("Contributor evidence: ", &resource))); - assert!(result.unwrap_err().contains("reserved"), "Accepted {path}"); - assert!( - document.finish().is_err(), - "Swallowing private registration failure must taint the document" - ); - } -} diff --git a/crates/doc-harness/tests/lifecycle.rs b/crates/doc-harness/tests/lifecycle.rs deleted file mode 100644 index 26add60..0000000 --- a/crates/doc-harness/tests/lifecycle.rs +++ /dev/null @@ -1,296 +0,0 @@ -use executable_docs::{ - Result, - lifecycle::{self, Files, Ownership}, -}; -use std::{collections::BTreeSet, fs, path::PathBuf}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - (temporary, root) -} - -fn legacy() -> Files { - Files::from([ - ("README.md".into(), b"\n# Existing guide\n\n![Example](assets/example.webp)\n".to_vec()), - ("manifest.txt".into(), b"n3 executable documentation v6\nrenderer: macos-metal\nassert: original assertion text\n".to_vec()), - ("assets/example.webp".into(), vec![82, 73, 70, 70, 0, 1, 2, 3]), - ]) -} - -fn accepted(_: &Files, _: &Files) -> Result<()> { - Ok(()) -} - -#[test] -fn dedicated_tree_preserves_every_legacy_byte_and_adds_no_metadata() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - assert!(lifecycle::read_tree(&root).unwrap().is_empty()); - assert!(!root.exists()); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert!(!root.join(".ownership.json").exists()); - assert!(!root.join("manifest.json").exists()); - - lifecycle::check(&root, &original, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - let mut next = original.clone(); - next.get_mut("assets/example.webp").unwrap().push(42); - assert!( - lifecycle::check(&root, &next, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("assets/example.webp") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - lifecycle::update(&root, &next, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), next); - assert_eq!( - fs::read(root.join("manifest.txt")).unwrap(), - original["manifest.txt"] - ); - assert_eq!( - fs::read_dir(&parent).unwrap().count(), - 1, - "No transaction files remain" - ); -} - -#[test] -fn dedicated_tree_refuses_unknown_and_retired_files_without_deleting_them() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - fs::write(root.join("unowned.txt"), "keep this file").unwrap(); - let with_unknown = lifecycle::read_tree(&root).unwrap(); - assert!( - lifecycle::update(&root, &original, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("unowned.txt") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), with_unknown); - fs::remove_file(root.join("unowned.txt")).unwrap(); - - let mut retired = original.clone(); - retired.remove("assets/example.webp"); - assert!( - lifecycle::update(&root, &retired, Ownership::Dedicated, accepted) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - lifecycle::update(&root, &retired, Ownership::Dedicated, accepted).unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), retired); -} - -#[test] -fn failed_or_panicking_preparation_never_reaches_legacy_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - let failure: Result = lifecycle::prepare(|| Err("behavior assertion failed".into())); - let result = - failure.and_then(|files| lifecycle::update(&root, &files, Ownership::Dedicated, accepted)); - assert!(result.unwrap_err().contains("behavior assertion failed")); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - - let failure: Result = lifecycle::prepare(|| panic!("capture failed")); - let result = - failure.and_then(|files| lifecycle::update(&root, &files, Ownership::Dedicated, accepted)); - assert!(result.unwrap_err().contains("panicked")); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn adapter_profile_policy_runs_before_check_or_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - let mut other = original.clone(); - other.insert("manifest.txt".into(), b"renderer: other-profile\n".to_vec()); - let reject = |current: &Files, candidate: &Files| -> Result<()> { - assert_eq!(current, &original); - assert_eq!(candidate, &other); - Err("renderer mismatch from adapter".into()) - }; - assert_eq!( - lifecycle::check(&root, &other, Ownership::Dedicated, reject).unwrap_err(), - "renderer mismatch from adapter" - ); - assert_eq!( - lifecycle::update(&root, &other, Ownership::Dedicated, reject).unwrap_err(), - "renderer mismatch from adapter" - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn generic_inventory_and_duplicate_checks_are_strict() { - let mut files = Files::new(); - lifecycle::insert(&mut files, "page.md".into(), b"first".to_vec()).unwrap(); - assert!( - lifecycle::insert(&mut files, "page.md".into(), b"second".to_vec()) - .unwrap_err() - .contains("Duplicate") - ); - assert_eq!(files["page.md"], b"first"); - for path in ["../escape", "/absolute", "a//b", "a/./b", "a\\b"] { - assert!(lifecycle::insert(&mut files, path.into(), Vec::new()).is_err()); - } - let expected = BTreeSet::from(["registered".into()]); - lifecycle::check_inventory(&expected, &expected).unwrap(); - let error = - lifecycle::check_inventory(&expected, &BTreeSet::from(["unexpected".into()])).unwrap_err(); - assert!(error.contains("Missing") && error.contains("unregistered")); -} - -#[test] -fn recorded_inventory_is_complete_before_validation_or_publication() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let original = legacy(); - lifecycle::build(&root, &original, Ownership::Recorded).unwrap(); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - let damaged = lifecycle::read_tree(&root).unwrap(); - let mut replacement = original.clone(); - replacement.remove("assets/example.webp"); - for candidate in [&original, &replacement] { - let called = std::cell::Cell::new(false); - let accept = |_: &Files, _: &Files| { - called.set(true); - Ok(()) - }; - for result in [ - lifecycle::check(&root, candidate, Ownership::Recorded, accept), - lifecycle::update(&root, candidate, Ownership::Recorded, accept), - ] { - assert!(result.unwrap_err().contains("missing assets/example.webp")); - assert!(!called.get()); - assert_eq!(lifecycle::read_tree(&root).unwrap(), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); - } - } -} - -#[test] -fn path_separation_rejects_lexical_overlap_without_creating_output() { - let (_temporary, parent) = directory(); - let baseline = parent.join("guide"); - for candidate in [&baseline, &baseline.join("candidate"), &parent] { - assert!( - lifecycle::ensure_separate_paths(&baseline, candidate) - .unwrap_err() - .contains("overlap") - ); - } - lifecycle::ensure_separate_paths(&baseline, &parent.join("guide-sibling")).unwrap(); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 0); -} - -#[test] -fn path_separation_uses_existing_identity_without_folding_distinct_names() { - let (_temporary, parent) = directory(); - let baseline = parent.join("Guide"); - let alias = parent.join("guide"); - fs::create_dir(&baseline).unwrap(); - fs::write(baseline.join("keep.txt"), "retained bytes").unwrap(); - let candidate = alias.join("candidate"); - - if alias.exists() { - assert!(same_file::is_same_file(&baseline, &alias).unwrap()); - for (left, right) in [(&baseline, &candidate), (&candidate, &baseline)] { - assert!( - lifecycle::ensure_separate_paths(left, right) - .unwrap_err() - .contains("overlap") - ); - } - } else { - // A case-sensitive host must keep genuinely distinct directories usable. - fs::create_dir(&alias).unwrap(); - assert!(!same_file::is_same_file(&baseline, &alias).unwrap()); - lifecycle::ensure_separate_paths(&baseline, &candidate).unwrap(); - lifecycle::ensure_separate_paths(&candidate, &baseline).unwrap(); - } - assert!(!candidate.exists()); - assert_eq!( - fs::read(baseline.join("keep.txt")).unwrap(), - b"retained bytes" - ); - assert_eq!(fs::read_dir(&baseline).unwrap().count(), 1); -} - -#[test] -fn dedicated_recovery_requires_the_adapters_backup_validation() { - let (_temporary, parent) = directory(); - let root = parent.join("guide"); - let backup = parent.join(".guide.backup"); - let original = legacy(); - lifecycle::update(&root, &original, Ownership::Dedicated, accepted).unwrap(); - fs::rename(&root, &backup).unwrap(); - assert!( - lifecycle::read_tree(&root) - .unwrap_err() - .contains("Interrupted transaction") - ); - fs::remove_file(backup.join("assets/example.webp")).unwrap(); - let error = lifecycle::recover_backup(&root, Ownership::Dedicated, |files| { - lifecycle::compare(&original, files) - }) - .unwrap_err(); - assert!(error.contains("missing assets/example.webp")); - assert!(!root.exists()); - fs::write( - backup.join("assets/example.webp"), - &original["assets/example.webp"], - ) - .unwrap(); - lifecycle::recover_backup(&root, Ownership::Dedicated, |files| { - lifecycle::compare(&original, files) - }) - .unwrap(); - assert_eq!(lifecycle::read_tree(&root).unwrap(), original); -} - -#[cfg(unix)] -#[test] -fn dedicated_paths_reject_symlinks_without_touching_targets() { - use std::os::unix::fs::symlink; - let (_temporary, parent) = directory(); - let protected = parent.join("protected"); - fs::create_dir(&protected).unwrap(); - fs::write(protected.join("keep"), "untouched").unwrap(); - let link = parent.join("linked"); - symlink(&protected, &link).unwrap(); - assert!( - lifecycle::read_tree(&link) - .unwrap_err() - .to_lowercase() - .contains("symlink") - ); - assert!( - lifecycle::update( - &link.join("guide"), - &legacy(), - Ownership::Dedicated, - accepted - ) - .is_err() - ); - let root = parent.join("guide"); - lifecycle::update(&root, &legacy(), Ownership::Dedicated, accepted).unwrap(); - fs::remove_file(root.join("assets/example.webp")).unwrap(); - symlink(protected.join("keep"), root.join("assets/example.webp")).unwrap(); - assert!(lifecycle::update(&root, &legacy(), Ownership::Dedicated, accepted).is_err()); - assert_eq!( - fs::read_to_string(protected.join("keep")).unwrap(), - "untouched" - ); -} diff --git a/crates/doc-harness/tests/model.rs b/crates/doc-harness/tests/model.rs deleted file mode 100644 index ac0f8b4..0000000 --- a/crates/doc-harness/tests/model.rs +++ /dev/null @@ -1,597 +0,0 @@ -use executable_docs::{Artifact, Audience, BindingValue, Doc, Document, IntoProse}; -use serde_json::{Value, json}; - -fn doc() -> Doc { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("initial-condition", true).unwrap(); - doc -} - -fn render(doc: Doc, audience: Audience) -> std::collections::BTreeMap> { - Document::render_many(&[doc.finish().unwrap()], audience).unwrap() -} - -#[test] -fn prose_macro_combines_many_mixed_parts_and_evaluates_each_once_in_order() { - let mut doc = doc(); - let count = doc.expect_eq("count", 3, 3).unwrap(); - let control = doc - .binding("save", BindingValue::Code("Save".into())) - .unwrap(); - let text = doc.text("result", "saved").unwrap(); - let mut evaluated = Vec::new(); - doc.markdown_parts(executable_docs::prose![ - { - evaluated.push(1); - "First: " - }, - &count, - ". Choose ", - &control, - "; read ", - text.link("the result"), - ". Then ", - &count, - " again. ", - { - evaluated.push(2); - String::from("Last: ") - }, - &control, - ".\n", - ]) - .unwrap(); - assert_eq!(evaluated, [1, 2]); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "First: 3. Choose Save; read [the result](assets/example/result.txt). Then 3 again. Last: Save.\n" - ); - let files = Document::render_many(&[document], Audience::Reader).unwrap(); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let references: Vec<_> = manifest["documents"][0]["blocks"][0]["references"] - .as_array() - .unwrap() - .iter() - .map(|reference| reference["id"].as_str().unwrap()) - .collect(); - assert_eq!(references, ["count", "save", "result", "count", "save"]); - let empty = executable_docs::prose![]; - assert!(empty.is_empty()); -} - -#[test] -fn swallowed_failure_duplicate_and_foreign_handles_taint_finish() { - let mut failed = doc(); - let _ = failed.expect_eq("result", 2, 3); - failed.paragraph("This must never publish.").unwrap(); - assert!(failed.finish().unwrap_err().contains("tainted")); - - let mut duplicate = doc(); - let _ = duplicate.require("initial-condition", true); - assert!(duplicate.finish().unwrap_err().contains("Duplicate")); - - let mut first = doc(); - let checked = first.expect_eq("observed", 42, 42).unwrap(); - let mut second = doc(); - second.expect_eq("observed", 42, 42).unwrap(); - let _ = second.paragraph(("Foreign value ", &checked)); - assert!( - second - .finish() - .unwrap_err() - .contains("Foreign document/run") - ); -} - -#[test] -fn value_and_file_bytes_are_frozen_at_observation() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("result.txt"); - std::fs::write(&path, "before").unwrap(); - let mut doc = doc(); - let checked = doc.expect_eq("count", 42_u32, 42_u32).unwrap(); - doc.paragraph(("Count: ", &checked)).unwrap(); - let resource = doc - .resource( - "result", - Artifact::from_file(&path, "text/plain", "txt", "test", "v1").unwrap(), - ) - .unwrap(); - std::fs::write(&path, "after").unwrap(); - doc.code(&resource, "text").unwrap(); - let files = render(doc, Audience::Reader); - assert_eq!(files["assets/example/result.txt"], b"before"); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - assert!( - manifest["documents"][0]["checks"] - .as_array() - .unwrap() - .iter() - .any(|check| check["id"] == "count" - && check["actual"] == 42 - && check["expected"] == 42) - ); - assert!(String::from_utf8_lossy(&files["example.md"]).contains("Count: 42")); -} - -#[test] -fn notes_and_their_values_resources_profiles_and_edges_are_absent_from_reader() { - let mut doc = doc(); - let public = doc.text_code("public", "public bytes", "text").unwrap(); - let private = doc - .resource( - "private-secret", - Artifact::new( - b"private-body".to_vec(), - "text/x-secret", - "secret", - "private-producer", - "private-profile", - ) - .unwrap(), - ) - .unwrap(); - let value = doc - .expect_eq( - "private-value", - "private-observation", - "private-observation", - ) - .unwrap(); - doc.note_on(&public, ("private-note ", &private, " observed ", &value)) - .unwrap(); - doc.note_on(&value, "Explain the checked observation.") - .unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - for (path, bytes) in &reader { - let text = String::from_utf8_lossy(bytes); - assert!(!path.contains("private")); - for secret in [ - "private-secret", - "private-note", - "private-body", - "private-observation", - "private-producer", - "private-profile", - ] { - assert!( - !text.contains(secret), - "Private evidence leaked into {path}: {text}" - ); - } - } - assert!(contributor.contains_key("assets/example/private-secret.secret")); - assert!( - String::from_utf8_lossy(&contributor["example.md"]) - .contains("Contributor note on `private-value`") - ); - assert!(String::from_utf8_lossy(&contributor["manifest.json"]).contains("private-observation")); -} - -#[test] -fn inserting_notes_never_changes_reader_block_ids_or_bytes() { - fn build(notes: bool) -> Doc { - let mut doc = doc(); - if notes { - doc.note("Before").unwrap(); - } - let block = doc.heading(2, "Section").unwrap(); - if notes { - doc.note_on(&block, "After").unwrap(); - } - doc.paragraph(("See ", &block)).unwrap(); - doc - } - assert_eq!( - render(build(false), Audience::Reader), - render(build(true), Audience::Reader) - ); -} - -#[test] -fn dangling_resources_and_public_references_to_private_blocks_fail() { - let mut unused = doc(); - unused.text("unused", "not placed").unwrap(); - assert!( - Document::render_many(&[unused.finish().unwrap()], Audience::Reader) - .unwrap_err() - .contains("Unreferenced resources") - ); - let mut leaked = doc(); - let note = leaked.note("Private").unwrap(); - leaked.paragraph(("See ", ¬e)).unwrap(); - assert!(leaked.finish().unwrap_err().contains("excluded block")); -} - -#[test] -fn wrong_text_kind_fails_even_when_error_is_swallowed() { - let mut doc = doc(); - let image = doc - .resource( - "image", - Artifact::new(vec![0, 1, 2], "image/png", "png", "test", "v1").unwrap(), - ) - .unwrap(); - let _ = doc.code(&image, "text"); - doc.embed(&image, "image").unwrap(); - assert!(doc.finish().unwrap_err().contains("not declared as text")); -} - -#[test] -fn resource_reuse_registers_bytes_once_and_code_fences_cannot_be_closed_by_content() { - let mut doc = doc(); - let resource = doc - .text_code( - "transcript", - "```\n[not a link](missing.md)\n`````\n", - "text", - ) - .unwrap(); - doc.paragraph(("Download ", &resource)).unwrap(); - doc.note_on(&resource, "One run supplies both uses.") - .unwrap(); - let files = render(doc, Audience::Contributor); - assert_eq!( - files - .keys() - .filter(|path| path.starts_with("assets/")) - .count(), - 1 - ); - assert!(String::from_utf8_lossy(&files["example.md"]).contains("``````text\n")); -} - -#[test] -fn markdown_references_use_the_real_parser_and_ignore_code_examples() { - let mut doc = doc(); - let resource = doc.json("data", &json!({"b": 2, "a": 1})).unwrap(); - doc.markdown("[saved data][result]\n\n[result]: assets/example/data.json \"JSON\"\n\n`[code](missing.md)`\n\n```text\n[also code](missing.md)\n```").unwrap(); - doc.note_on(&resource, "Source resource").unwrap(); - let files = render(doc, Audience::Reader); - assert!(files.contains_key("assets/example/data.json")); - let mut unresolved = doc_for_reference(); - let _ = unresolved.markdown("[missing][definition]"); - assert!( - unresolved - .finish() - .unwrap_err() - .contains("Unresolved Markdown") - ); -} - -fn doc_for_reference() -> Doc { - doc() -} - -#[test] -fn raw_cross_document_links_wait_for_complete_bundle_but_unknown_and_hidden_links_fail() { - let mut first = Doc::new("first", "First").unwrap(); - first.require("checked", true).unwrap(); - first.markdown("[second](second.md)").unwrap(); - let first = first.finish().unwrap(); - assert!(Document::render_many(std::slice::from_ref(&first), Audience::Reader).is_err()); - let mut second = Doc::new("second", "Second").unwrap(); - second.require("checked", true).unwrap(); - second.paragraph("Second document").unwrap(); - assert!(Document::render_many(&[first, second.finish().unwrap()], Audience::Reader).is_ok()); -} - -#[test] -fn raw_html_unsafe_schemes_and_encoded_traversal_fail() { - for markdown in [ - "", - "[bad](javascript:alert)", - "[bad](%2e%2e/secret.txt)", - ] { - let mut doc = doc(); - let result = doc.markdown(markdown); - assert!( - result.is_err() - || doc - .finish() - .and_then(|document| Document::render_many(&[document], Audience::Reader)) - .is_err(), - "Accepted {markdown}" - ); - } -} - -#[test] -fn repeated_runs_are_identical_and_registration_order_is_canonical() { - fn build(reverse: bool) -> Document { - let mut doc = doc(); - let mut names = ["first", "second"]; - if reverse { - names.reverse(); - } - let mut resources = std::collections::BTreeMap::new(); - for name in names { - resources.insert(name, doc.text(name, name).unwrap()); - } - for resource in resources.values() { - doc.code(resource, "text").unwrap(); - } - doc.finish().unwrap() - } - let first = Document::render_many(&[build(false)], Audience::Reader).unwrap(); - let second = Document::render_many(&[build(true)], Audience::Reader).unwrap(); - assert_eq!(first, second); -} - -#[test] -fn resource_code_preserves_custom_provenance_and_failure_taints_the_document() { - let mut complete = doc(); - let resource = complete - .resource_code( - "command", - Artifact::new( - b"actual stdout", - "text/plain", - "txt", - "application-cli", - "stdout-v1", - ) - .unwrap(), - "text", - ) - .unwrap(); - complete - .note_on(&resource, "Produced by the real command.") - .unwrap(); - let files = render(complete, Audience::Reader); - assert_eq!(files["assets/example/command.txt"], b"actual stdout"); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["id"] == "command") - .unwrap(); - assert_eq!(record["producer"], "application-cli"); - assert_eq!(record["profile"], "stdout-v1"); - - let mut failed = doc(); - let _ = failed.resource_code( - "binary", - Artifact::new( - vec![0xff], - "text/plain", - "txt", - "application-cli", - "stdout-v1", - ) - .unwrap(), - "text", - ); - assert!(failed.finish().unwrap_err().contains("not UTF-8")); -} - -#[test] -fn legacy_fragment_preserves_markdown_whitespace_and_typed_control_media_markup() { - let mut doc = doc(); - let control = doc - .binding("control", BindingValue::Code("File & \"'|".into())) - .unwrap(); - let shortcut = doc - .binding( - "shortcut", - BindingValue::Keys(vec!["Command".into(), "O".into()]), - ) - .unwrap(); - let image = doc - .resource_at( - "preview", - Artifact::new( - b"frozen image", - "image/webp", - "webp", - "host-renderer", - "metal-v1", - ) - .unwrap(), - "assets/legacy-preview.webp", - ) - .unwrap(); - doc.markdown_parts(vec![ - "# Existing guide\n\nChoose ".into_prose(), - (&control).into_prose(), - " or press ".into_prose(), - (&shortcut).into_prose(), - ".\n\n".into_prose(), - image.image("Legacy preview"), - "\n\n[Related](other.md#existing-heading)\n".into_prose(), - ]) - .unwrap(); - doc.note("Contributor explanation.").unwrap(); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "# Existing guide\n\nChoose File & <View> "'| or press Command + O.\n\n![Legacy preview](assets/legacy-preview.webp)\n\n[Related](other.md#existing-heading)\n" - ); - assert!( - Document::render_many(&[document], Audience::Reader).is_err(), - "The ordinary bundle renderer still owns strict whole-bundle link checking" - ); -} - -#[test] -fn typed_bindings_and_image_views_preserve_audience_isolation() { - fn build(notes: bool) -> Document { - let mut doc = doc(); - let public = doc - .binding("public-label", BindingValue::Text("Visible".into())) - .unwrap(); - doc.markdown_parts(("A **", &public, "** label.\n")) - .unwrap(); - if notes { - let private = doc - .binding("secret-label", BindingValue::Keys(vec!["SECRET".into()])) - .unwrap(); - let image = doc - .resource_at( - "secret-image", - Artifact::new( - b"SECRET BYTES", - "image/png", - "png", - "secret-producer", - "secret-profile", - ) - .unwrap(), - "assets/secret.png", - ) - .unwrap(); - doc.note((&private, ": ", image.image("secret image"))) - .unwrap(); - } - doc.finish().unwrap() - } - let ordinary = Document::render_many(&[build(false)], Audience::Reader).unwrap(); - let annotated = Document::render_many(&[build(true)], Audience::Reader).unwrap(); - assert_eq!(ordinary, annotated); - let contributor = Document::render_many(&[build(true)], Audience::Contributor).unwrap(); - assert!(contributor.contains_key("assets/secret.png")); - assert!(String::from_utf8_lossy(&contributor["manifest.json"]).contains("secret-label")); -} - -#[test] -fn fragment_resource_files_exclude_note_only_bytes_from_reader() { - let mut doc = doc(); - let diagnostic = doc.text("diagnostic", "private frozen bytes").unwrap(); - doc.markdown_parts("Public explanation.\n").unwrap(); - doc.note(("Contributor diagnosis: ", &diagnostic)).unwrap(); - let document = doc.finish().unwrap(); - assert_eq!( - document.render_fragment(Audience::Reader).unwrap(), - "Public explanation.\n" - ); - assert!( - document - .resource_files(Audience::Reader) - .unwrap() - .is_empty() - ); - let files = document.resource_files(Audience::Contributor).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!( - files["assets/example/diagnostic.txt"], - b"private frozen bytes" - ); - assert_eq!( - files, - document.resource_files(Audience::Contributor).unwrap() - ); -} - -#[test] -fn binding_and_media_composition_rejects_foreign_handles_wrong_mime_and_literal_html() { - let mut first = doc(); - let foreign = first - .binding("label", BindingValue::Text("Label".into())) - .unwrap(); - let mut second = doc(); - assert!(second.markdown_parts(("Foreign ", &foreign)).is_err()); - assert!(second.finish().is_err()); - - let mut wrong_kind = doc(); - let text = wrong_kind.text("plain", "bytes").unwrap(); - assert!( - wrong_kind - .markdown_parts(text.image("Not an image")) - .is_err() - ); - assert!(wrong_kind.finish().is_err()); - - let mut html = doc(); - let tag = html - .binding("tag", BindingValue::Text("script".into())) - .unwrap(); - assert!(html.markdown_parts(("<", &tag, ">bad")).is_err()); - assert!(html.finish().is_err()); -} - -#[test] -fn custom_resource_paths_cannot_overwrite_another_document_or_resource() { - fn document(id: &str, path: &str) -> Document { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("executed", true).unwrap(); - let resource = doc - .resource_at( - "shared", - Artifact::new(b"bytes", "text/plain", "txt", "host", "v1").unwrap(), - path, - ) - .unwrap(); - doc.paragraph(resource.link("Download")).unwrap(); - doc.finish().unwrap() - } - let error = Document::render_many( - &[ - document("first", "assets/shared.txt"), - document("second", "assets/shared.txt"), - ], - Audience::Reader, - ) - .unwrap_err(); - assert!(error.contains("Duplicate artifact path")); - let error = Document::render_many( - &[ - document("first", "second.md"), - document("second", "assets/second.txt"), - ], - Audience::Reader, - ) - .unwrap_err(); - assert!(error.contains("Duplicate artifact path")); - let mut unsafe_path = doc(); - assert!( - unsafe_path - .resource_at( - "bad", - Artifact::new(b"bytes", "text/plain", "txt", "host", "v1").unwrap(), - "../bad.txt" - ) - .is_err() - ); - assert!(unsafe_path.finish().is_err()); - - let private_document = |id: &str| { - let mut doc = Doc::new(id, id).unwrap(); - doc.require("checked", true).unwrap(); - let resource = doc - .resource_at( - "private", - Artifact::new(b"private", "text/plain", "txt", "host", "v1").unwrap(), - "assets/private.txt", - ) - .unwrap(); - doc.note(("Internal observation: ", &resource)).unwrap(); - doc.finish().unwrap() - }; - assert!( - Document::render_many( - &[private_document("first"), private_document("second")], - Audience::Reader - ) - .is_err(), - "Reader selection cannot conceal duplicate contributor ownership" - ); -} - -#[test] -fn fragment_rejects_block_links_without_generated_anchors() { - let mut doc = doc(); - let heading = doc.heading(2, "Section").unwrap(); - doc.paragraph(("See ", &heading)).unwrap(); - let document = doc.finish().unwrap(); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("omits block anchors") - ); - assert!(Document::render_many(&[document], Audience::Reader).is_ok()); -} diff --git a/crates/doc-harness/tests/prose.rs b/crates/doc-harness/tests/prose.rs deleted file mode 100644 index 3e9494e..0000000 --- a/crates/doc-harness/tests/prose.rs +++ /dev/null @@ -1,107 +0,0 @@ -use executable_docs::{Audience, BindingValue, Doc, Document, IntoProse, Prose, prose}; -use std::cell::Cell; - -#[test] -fn named_prose_preserves_typed_evidence_and_exact_text() { - fn document(named: bool) -> Document { - let mut doc = Doc::new("prose", "Prose").unwrap(); - let count = doc.expect_eq("count", 3, 3).unwrap(); - let key = doc - .binding("key", BindingValue::Keys(vec!["Space".into()])) - .unwrap(); - let parts = if named { - prose!( - "Press {key} for **{count}** items. Again: {count}. {{literal}}\n", - key = &key, - count = &count - ) - .unwrap() - } else { - prose![ - "Press ", - &key, - " for **", - &count, - "** items. Again: ", - &count, - ". {literal}\n" - ] - .into_prose() - }; - doc.markdown_parts(parts).unwrap(); - doc.finish().unwrap() - } - for audience in [Audience::Reader, Audience::Contributor] { - assert_eq!( - Document::render_many(&[document(true)], audience).unwrap(), - Document::render_many(&[document(false)], audience).unwrap(), - ); - } -} - -#[test] -fn named_expressions_run_once_even_when_referenced_twice() { - let evaluations = Cell::new(0); - let parts = prose!( - "{value} and {value}", - value = { - evaluations.set(evaluations.get() + 1); - "one" - } - ) - .unwrap(); - assert_eq!(evaluations.get(), 1); - let mut doc = Doc::new("once", "Once").unwrap(); - doc.require("evaluated-once", evaluations.get() == 1) - .unwrap(); - doc.paragraph(parts).unwrap(); - let text = doc - .finish() - .unwrap() - .render_fragment(Audience::Reader) - .unwrap(); - assert_eq!(text, "one and one\n\n"); -} - -#[test] -fn invalid_interpolation_fails_instead_of_dropping_evidence() { - for text in [ - "{missing}", - "{value", - "value}", - "{value:?}", - "no placeholders", - ] { - assert!(Prose::interpolate(text, [("value", "x".into_prose())]).is_err()); - } -} - -#[test] -fn duplicate_names_and_foreign_handles_remain_errors() { - assert!(Prose::interpolate("{x}", [("x", "a".into_prose()), ("x", "b".into_prose())]).is_err()); - let mut first = Doc::new("first", "First").unwrap(); - let value = first.expect_eq("value", 3, 3).unwrap(); - let mut second = Doc::new("second", "Second").unwrap(); - second.require("check", true).unwrap(); - assert!( - second - .paragraph(prose!("Observed {value}", value = &value).unwrap()) - .is_err() - ); - assert!(second.finish().is_err()); -} - -#[test] -fn private_interpolated_resources_stay_private() { - let mut doc = Doc::new("notes", "Notes").unwrap(); - doc.require("check", true).unwrap(); - doc.paragraph("Public explanation.").unwrap(); - let private = doc.text("private", "private bytes").unwrap(); - doc.note(prose!("Internal evidence: {private}", private = &private).unwrap()) - .unwrap(); - let document = doc.finish().unwrap(); - let reader = Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap(); - assert!(!reader.contains_key("assets/notes/private.txt")); - let contributor = Document::render_many(&[document], Audience::Contributor).unwrap(); - assert_eq!(contributor["assets/notes/private.txt"], b"private bytes"); -} diff --git a/crates/doc-harness/tests/render_review.rs b/crates/doc-harness/tests/render_review.rs deleted file mode 100644 index b3f7dcc..0000000 --- a/crates/doc-harness/tests/render_review.rs +++ /dev/null @@ -1,162 +0,0 @@ -use executable_docs::{Artifact, Audience, BindingValue, Doc, Document, ExportLayout}; -use pulldown_cmark::{Event, Parser, Tag, html}; - -fn doc() -> Doc { - let mut doc = Doc::new("review", "Renderer review").unwrap(); - doc.require("exercised", true).unwrap(); - doc -} - -fn render(doc: Doc) -> Result>, String> { - Document::render_many(&[doc.finish()?], Audience::Reader) -} - -#[test] -fn interpolated_delimiter_cannot_expose_authored_html() { - let mut doc = doc(); - let delimiter = doc.expect_eq("delimiter", "`", "`").unwrap(); - doc.markdown_parts(("`", &delimiter, "`")) - .unwrap(); - assert!(render(doc).unwrap_err().contains("Raw HTML")); -} - -#[test] -fn generated_html_has_no_permission_to_cover_adjacent_authored_tags() { - let mut doc = doc(); - let delimiter = doc.expect_eq("delimiter", "`", "`").unwrap(); - let code = doc - .binding("label", BindingValue::Code("safe".into())) - .unwrap(); - doc.markdown_parts(("`", &delimiter, "` ", &code)) - .unwrap(); - assert!(render(doc).unwrap_err().contains("Raw HTML")); -} - -#[test] -fn typed_html_cannot_silently_become_code_text() { - let mut doc = doc(); - let code = doc - .binding("label", BindingValue::Code("safe".into())) - .unwrap(); - doc.markdown_parts(("`", &code, "`\n")).unwrap(); - assert!(render(doc).unwrap_err().contains("swallowed")); -} - -#[test] -fn code_and_key_bindings_remain_literal_in_markdown_and_contributor_notes() { - let mut doc = doc(); - let label = doc - .binding( - "label", - BindingValue::Code("*literal* `tick` [x] & ".into()), - ) - .unwrap(); - let keys = doc - .binding( - "keys", - BindingValue::Keys(vec!["Command".into(), "`".into()]), - ) - .unwrap(); - doc.markdown_parts(("Choose ", &label, " with ", &keys, ".\n\n")) - .unwrap(); - doc.note(("Again ", &label, " with ", &keys)).unwrap(); - let files = Document::render_many(&[doc.finish().unwrap()], Audience::Contributor).unwrap(); - let page = std::str::from_utf8(&files["review.md"]).unwrap(); - let mut html = String::new(); - html::push_html(&mut html, Parser::new(page)); - assert!(html.contains("*literal* `tick` [x] & <y>")); - assert!(html.contains("Command + `")); - assert!(html.contains("Contributor note")); - assert!(!html.contains("literal")); -} - -#[test] -fn resource_destinations_encode_entities_and_unicode_without_renaming_files() { - let mut doc = doc(); - let path = "assets/a©-é;'!.txt"; - let resource = doc - .resource_at( - "result", - Artifact::new(b"data", "text/plain", "txt", "test", "v1").unwrap(), - path, - ) - .unwrap(); - doc.paragraph(resource.link("Download")).unwrap(); - doc.embed(&resource, "Embedded download").unwrap(); - let files = render(doc).unwrap(); - assert_eq!(files[path], b"data"); - let page = std::str::from_utf8(&files["review.md"]).unwrap(); - let destinations: Vec<_> = Parser::new(page) - .filter_map(|event| match event { - Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url.into_string()), - _ => None, - }) - .collect(); - assert_eq!(destinations, ["assets/a%26copy%3B-%C3%A9%3B%27%21.txt"; 2]); -} - -#[test] -fn unclosed_fence_cannot_swallow_following_blocks_in_pages_or_fragments() { - for fence in ["```rust\nlet value = 3;", "~~~text\nunclosed"] { - let mut doc = doc(); - doc.markdown(fence).unwrap(); - doc.heading(2, "Must remain a heading").unwrap(); - let document = doc.finish().unwrap(); - let error = - Document::render_many(std::slice::from_ref(&document), Audience::Reader).unwrap_err(); - assert!(error.contains("boundary"), "{error}"); - assert!( - document - .render_fragment(Audience::Reader) - .unwrap_err() - .contains("boundary") - ); - } -} - -#[test] -fn closed_code_containing_html_and_backticks_keeps_following_anchors_active() { - let mut doc = doc(); - doc.markdown("````text\n\n```\n````") - .unwrap(); - let heading = doc.heading(2, "Still visible").unwrap(); - doc.paragraph(("Continue at ", &heading)).unwrap(); - let files = render(doc).unwrap(); - let mut html = String::new(); - html::push_html( - &mut html, - Parser::new(std::str::from_utf8(&files["review.md"]).unwrap()), - ); - assert!(html.contains("

Still visible

")); - assert!(html.contains("")); - assert!(html.contains("<img src=x onerror=alert(1)>")); - assert!(!html.contains(" (tempfile::TempDir, PathBuf) { - let temporary = tempfile::tempdir().unwrap(); - let path = temporary.path().canonicalize().unwrap(); - (temporary, path) -} - -fn guide() -> Vec { - let mut doc = Doc::new("guide", "Guide").unwrap(); - doc.require("completed", true).unwrap(); - doc.paragraph("Public explanation").unwrap(); - let secret = doc.text("secret", "private evidence").unwrap(); - doc.note(("Contributor attachment: ", &secret)).unwrap(); - vec![doc.finish().unwrap()] -} - -#[test] -fn missing_audience_aliases_fail_before_generation_in_either_order() { - for (reader, contributor) in [ - ("Manual", "manual"), - ("Manual", "manual/internal"), - ("Reader", "Reader."), - ("Reader", "Reader "), - ("LongDirectory", "LONGDI~1"), - ("caf\u{e9}", "cafe\u{301}"), - ("\u{c5}", "\u{e5}"), - ] { - for audiences in [ - [Audience::Reader, Audience::Contributor], - [Audience::Contributor, Audience::Reader], - ] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader, "public") - .unwrap() - .output(Audience::Contributor, contributor, "internal") - .unwrap(); - let invoked = Cell::new(false); - let error = run(&config, RunMode::Update, &audiences, || { - invoked.set(true); - Ok(guide()) - }) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!invoked.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } - } -} - -#[test] -fn candidate_aliases_and_unselected_baseline_aliases_fail_without_writes() { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "Manual", "Site") - .unwrap() - .output(Audience::Contributor, "Private", "site") - .unwrap(); - for (destination, audiences) in [ - ("candidate", vec![Audience::Contributor, Audience::Reader]), - ("manual/candidate", vec![Audience::Reader]), - ("private/candidate", vec![Audience::Reader]), - ] { - let invoked = Cell::new(false); - let error = run( - &config, - RunMode::Build(destination.into()), - &audiences, - || { - invoked.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!invoked.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } -} - -#[test] -fn existing_unicode_parents_and_ascii_discriminators_support_unicode_paths() { - let (_temporary, root) = root(); - let project = root.join("\u{51fa}\u{7248}\u{793e}"); - fs::create_dir(&project).unwrap(); - let config = RunnerConfig::new(&project) - .unwrap() - .output( - Audience::Reader, - "public/\u{516c}\u{958b}", - "public/\u{516c}\u{958b}", - ) - .unwrap() - .output( - Audience::Contributor, - "private/\u{5185}\u{90e8}", - "private/\u{5185}\u{90e8}", - ) - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - assert!(project.join("public/\u{516c}\u{958b}/guide.md").is_file()); - assert!( - !project - .join("public/\u{516c}\u{958b}/assets/guide/secret.txt") - .exists() - ); - assert!( - project - .join("private/\u{5185}\u{90e8}/assets/guide/secret.txt") - .is_file() - ); -} - -#[test] -fn precreated_distinct_directories_prove_separation_without_ascii_names() { - let (_temporary, root) = root(); - let reader = root.join("\u{516c}\u{958b}"); - let contributor = root.join("\u{5185}\u{90e8}"); - fs::create_dir(&reader).unwrap(); - fs::create_dir(&contributor).unwrap(); - assert!(!same_file::is_same_file(&reader, &contributor).unwrap()); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader.join("output"), "public") - .unwrap() - .output(Audience::Contributor, contributor.join("output"), "private") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - assert!(reader.join("output/guide.md").is_file()); - assert!(!reader.join("output/assets/guide/secret.txt").exists()); - assert!(contributor.join("output/assets/guide/secret.txt").is_file()); -} - -#[test] -fn distinct_existing_ancestor_and_descendant_can_own_separate_missing_branches() { - let (_temporary, root) = root(); - fs::create_dir(root.join("existing")).unwrap(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "public", "site") - .unwrap() - .output(Audience::Contributor, "existing/private", "review") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Reader, Audience::Contributor], - || Ok(guide()), - ) - .unwrap(); - assert!(root.join("public/guide.md").is_file()); - assert!(root.join("existing/private/guide.md").is_file()); -} - -#[test] -fn existing_case_distinctions_follow_filesystem_identity() { - let (_temporary, root) = root(); - let upper = root.join("Existing"); - let lower = root.join("existing"); - fs::create_dir(&upper).unwrap(); - let aliases = lower.exists(); - if !aliases { - fs::create_dir(&lower).unwrap(); - } - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, upper.join("Manual"), "site") - .unwrap() - .output(Audience::Contributor, lower.join("manual"), "review") - .unwrap(); - let invoked = Cell::new(false); - let result = run( - &config, - RunMode::Update, - &[Audience::Contributor, Audience::Reader], - || { - invoked.set(true); - Ok(guide()) - }, - ); - if aliases { - assert!(result.unwrap_err().contains("overlap")); - assert!(!invoked.get()); - assert!(!upper.join("Manual").exists()); - } else { - result.unwrap(); - assert!(invoked.get()); - assert!(upper.join("Manual/guide.md").is_file()); - assert!(!upper.join("Manual/assets/guide/secret.txt").exists()); - assert!(lower.join("manual/assets/guide/secret.txt").is_file()); - } -} diff --git a/crates/doc-harness/tests/runner_config.rs b/crates/doc-harness/tests/runner_config.rs deleted file mode 100644 index 22037a6..0000000 --- a/crates/doc-harness/tests/runner_config.rs +++ /dev/null @@ -1,343 +0,0 @@ -use executable_docs::{ - Audience, Doc, Document, ExportLayout, - runner::{RunMode, RunnerConfig, run, run_cli_with}, -}; -use std::{cell::Cell, fs, path::Path}; - -fn guide() -> Vec { - let mut doc = Doc::new("setup", "Setup").unwrap(); - let value = doc - .expect_eq("trimmed", " ready ".trim(), "ready") - .unwrap(); - doc.paragraph(("Status: ", &value)).unwrap(); - let private = doc.text("internal", "not for readers").unwrap(); - doc.note(("Review ", &private)).unwrap(); - vec![doc.finish().unwrap()] -} - -fn root() -> (tempfile::TempDir, std::path::PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().canonicalize().unwrap(); - (temp, path) -} - -#[test] -fn configured_roots_and_nested_exports_work_without_project_discovery() { - let (_temp, root) = root(); - let project = root.join("application"); - fs::create_dir(&project).unwrap(); - fs::write(project.join("owned-by-adopter"), "preserve").unwrap(); - let layout = ExportLayout::default() - .page("setup", "handbook/start/index.md") - .unwrap() - .resources_under("evidence") - .unwrap(); - let config = RunnerConfig::new(&project) - .unwrap() - .output(Audience::Reader, "published-manual", "site") - .unwrap() - .output( - Audience::Contributor, - root.join("private-review"), - "review/internal", - ) - .unwrap() - .layout(layout); - let calls = Cell::new(0); - let generate = || { - calls.set(calls.get() + 1); - Ok(guide()) - }; - run_cli_with(["update"].map(str::to_owned), &config, generate).unwrap(); - assert_eq!( - calls.get(), - 1, - "both configured defaults reuse one execution" - ); - run( - &config, - RunMode::Check, - &[Audience::Reader, Audience::Contributor], - generate, - ) - .unwrap(); - run_cli_with( - ["build", "--out", "review-candidate"].map(str::to_owned), - &config, - generate, - ) - .unwrap(); - assert_eq!(calls.get(), 3); - for directory in [ - project.join("published-manual"), - project.join("review-candidate/site"), - ] { - assert!(directory.join("handbook/start/index.md").is_file()); - assert!(!directory.join("evidence/setup/internal.txt").exists()); - assert!( - !fs::read_to_string(directory.join("manifest.json")) - .unwrap() - .contains("internal") - ); - } - assert!( - root.join("private-review/evidence/setup/internal.txt") - .is_file() - ); - assert!( - project - .join("review-candidate/review/internal/evidence/setup/internal.txt") - .is_file() - ); - assert_eq!( - fs::read_to_string(project.join("owned-by-adopter")).unwrap(), - "preserve" - ); - assert!(!project.join("reader").exists()); - assert!(!project.join("contributor").exists()); -} - -#[test] -fn single_audience_can_publish_directly_at_a_build_root() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Contributor, "retained", "") - .unwrap(); - run_cli_with( - ["build", "--out", "candidate"].map(str::to_owned), - &config, - || Ok(guide()), - ) - .unwrap(); - assert!(root.join("candidate/setup.md").is_file()); - assert!(!root.join("candidate/contributor").exists()); -} - -#[test] -fn filesystem_root_is_a_valid_base_but_never_an_output() { - let (_temp, root) = root(); - let filesystem_root = root.ancestors().last().unwrap(); - let config = RunnerConfig::new(filesystem_root) - .unwrap() - .output(Audience::Reader, root.join("retained"), "") - .unwrap(); - assert_eq!(config.base_dir(), filesystem_root); - run( - &config, - RunMode::Build(root.join("candidate")), - &[Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - let invoked = Cell::new(false); - assert!( - run( - &config, - RunMode::Build(filesystem_root.to_owned()), - &[Audience::Reader], - || { - invoked.set(true); - Ok(guide()) - } - ) - .is_err() - ); - assert!(!invoked.get()); -} - -#[test] -fn invalid_destination_selection_fails_before_user_generation() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "") - .unwrap() - .output(Audience::Contributor, "private", "review") - .unwrap(); - let invoked = Cell::new(false); - let generate = || { - invoked.set(true); - Ok(guide()) - }; - for (mode, audiences) in [ - ( - RunMode::Build("candidate".into()), - vec![Audience::Reader, Audience::Contributor], - ), - (RunMode::Check, vec![Audience::Reader, Audience::Reader]), - (RunMode::Check, vec![]), - ( - RunMode::Build("private/candidate".into()), - vec![Audience::Reader], - ), - (RunMode::Build("../outside".into()), vec![Audience::Reader]), - ] { - assert!(run(&config, mode, &audiences, generate).is_err()); - assert!(!invoked.get()); - } - assert!(!root.join("candidate").exists()); - let overlapping = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap() - .output(Audience::Contributor, "retained/private", "review") - .unwrap(); - assert!(run(&overlapping, RunMode::Update, &[Audience::Reader], generate).is_err()); - assert!(!invoked.get()); - assert!(!root.join("retained").exists()); -} - -#[test] -fn invalid_candidate_directories_and_duplicate_configurations_reject() { - let (_temp, root) = root(); - for path in ["../outside", "/absolute", "a/../b", "./current"] { - assert!( - RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", Path::new(path)) - .is_err() - ); - } - assert!( - RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "one", "one") - .unwrap() - .output(Audience::Reader, "two", "two") - .is_err() - ); -} - -#[test] -fn explicit_default_selection_does_not_publish_other_views() { - let (_temp, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap() - .output(Audience::Contributor, "private", "review") - .unwrap() - .default_audiences([Audience::Reader]); - run_cli_with(["update"].map(str::to_owned), &config, || Ok(guide())).unwrap(); - assert!(root.join("retained/setup.md").exists()); - assert!(!root.join("private").exists()); - let missing = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "retained", "site") - .unwrap(); - let invoked = Cell::new(false); - assert!( - run_cli_with( - ["check", "--audience", "contributor"].map(str::to_owned), - &missing, - || { - invoked.set(true); - Ok(guide()) - } - ) - .is_err() - ); - assert!(!invoked.get()); -} - -#[test] -fn builds_cannot_occupy_retained_transaction_paths() { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "manual", "") - .unwrap(); - run( - &config, - RunMode::Update, - &[Audience::Reader], - || Ok(guide()), - ) - .unwrap(); - for destination in [ - ".manual.lock", - ".manual.staging", - ".manual.backup", - ".manual.staging/review", - ".MANUAL.backup", - ".MANUAL.backup/review", - ] { - let called = Cell::new(false); - let error = run( - &config, - RunMode::Build(destination.into()), - &[Audience::Reader], - || { - called.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{destination}: {error}"); - assert!(!called.get(), "{destination}"); - assert!(!root.join(destination).exists(), "{destination}"); - run(&config, RunMode::Check, &[Audience::Reader], || Ok(guide())).unwrap(); - } -} - -#[test] -fn configured_audiences_cannot_occupy_each_others_transaction_paths() { - for reserved in [ - ".manual.lock", - ".manual.staging", - ".manual.backup", - ".manual.backup/review", - ] { - for (reader, contributor) in [("manual", reserved), (reserved, "manual")] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, reader, "public") - .unwrap() - .output(Audience::Contributor, contributor, "internal") - .unwrap(); - let called = Cell::new(false); - let error = run(&config, RunMode::Update, &[Audience::Reader], || { - called.set(true); - Ok(guide()) - }) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!called.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } - } -} - -#[test] -fn candidate_audience_paths_reserve_their_own_transaction_paths() { - for reserved in [ - ".public.lock", - ".public.staging", - ".public.backup", - ".public.backup/review", - ] { - let (_temporary, root) = root(); - let config = RunnerConfig::new(&root) - .unwrap() - .output(Audience::Reader, "manual", "public") - .unwrap() - .output(Audience::Contributor, "private", reserved) - .unwrap(); - let called = Cell::new(false); - let error = run( - &config, - RunMode::Build("candidate".into()), - &[Audience::Reader, Audience::Contributor], - || { - called.set(true); - Ok(guide()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap"), "{error}"); - assert!(!called.get()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - } -} diff --git a/crates/doc-harness/tests/section_review.rs b/crates/doc-harness/tests/section_review.rs deleted file mode 100644 index a9305b0..0000000 --- a/crates/doc-harness/tests/section_review.rs +++ /dev/null @@ -1,86 +0,0 @@ -use executable_docs::{Audience, Doc, Document}; -use serde_json::Value; - -fn doc() -> Doc { - let mut doc = Doc::new("sections", "Sections").unwrap(); - doc.require("executed", true).unwrap(); - doc -} - -#[test] -fn named_section_links_and_notes_keep_their_target_after_insertions() { - for prefix in [false, true] { - let mut doc = doc(); - if prefix { - doc.paragraph("An unrelated paragraph inserted later.") - .unwrap(); - } - let section = doc.heading_with_id("workflow", 2, "Workflow").unwrap(); - assert_eq!(section.id(), "workflow"); - doc.paragraph(section.link("Read the workflow")).unwrap(); - doc.note_on(§ion, "A contributor decision attached to this section.") - .unwrap(); - let document = doc.finish().unwrap(); - for audience in [Audience::Reader, Audience::Contributor] { - let files = Document::render_many(std::slice::from_ref(&document), audience).unwrap(); - let page = std::str::from_utf8(&files["sections.md"]).unwrap(); - assert!(page.contains("\n\n## Workflow")); - assert!(page.contains("[Read the workflow](#workflow)")); - assert_eq!( - page.contains("Contributor note on [workflow](#workflow)"), - audience == Audience::Contributor - ); - let manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - let blocks = manifest["documents"][0]["blocks"].as_array().unwrap(); - assert!(blocks.iter().any(|block| block["id"] == "workflow")); - assert!(blocks.iter().any(|block| { - block["references"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference["id"] == "workflow" && reference["kind"] == "block") - })); - } - } -} - -#[test] -fn explicit_section_ids_share_evidence_namespace_and_fail_closed() { - for id in ["executed", "Invalid ID"] { - let mut doc = doc(); - assert!(doc.heading_with_id(id, 2, "Invalid").is_err()); - assert!(doc.finish().unwrap_err().contains("tainted")); - } - let mut duplicate = doc(); - duplicate.heading_with_id("workflow", 2, "First").unwrap(); - assert!(duplicate.heading_with_id("workflow", 2, "Second").is_err()); - assert!(duplicate.finish().unwrap_err().contains("tainted")); - - let mut automatic_collision = doc(); - automatic_collision - .heading_with_id("block-0002", 2, "Explicit") - .unwrap(); - assert!(automatic_collision.heading(2, "Automatic").is_err()); - assert!( - automatic_collision - .finish() - .unwrap_err() - .contains("tainted") - ); -} - -#[test] -fn labeled_links_cannot_leak_notes_or_cross_document_ownership() { - let mut private = doc(); - let note = private.note("Private detail").unwrap(); - private - .paragraph(note.link("Read the hidden note")) - .unwrap(); - assert!(private.finish().unwrap_err().contains("excluded block")); - - let mut source = doc(); - let section = source.heading_with_id("workflow", 2, "Workflow").unwrap(); - let mut foreign = doc(); - assert!(foreign.paragraph(section.link("Wrong owner")).is_err()); - assert!(foreign.finish().unwrap_err().contains("tainted")); -} diff --git a/crates/doc-harness/tests/store.rs b/crates/doc-harness/tests/store.rs deleted file mode 100644 index ee1211a..0000000 --- a/crates/doc-harness/tests/store.rs +++ /dev/null @@ -1,906 +0,0 @@ -use executable_docs::{Artifact, Audience, Doc, Document, runner::run_cli, store}; -use std::{ - collections::BTreeMap, - fs, - path::{Path, PathBuf}, -}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - (temp, root) -} - -fn files(audience: &str, profile: &str, text: &str) -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - let resource = doc - .resource_at( - "content", - Artifact::new(text.as_bytes(), "text/plain", "md", "text", profile).unwrap(), - "pages/example.md", - ) - .unwrap(); - doc.code(&resource, "text").unwrap(); - Document::render_many( - &[doc.finish().unwrap()], - if audience == "reader" { - Audience::Reader - } else { - Audience::Contributor - }, - ) - .unwrap() -} - -fn without_content() -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - doc.paragraph("Content has been retired.").unwrap(); - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn retained_receipts_accept_unknown_fields_and_null_or_omitted_check_payloads() { - let (_temporary, parent) = directory(); - let root = parent.join("reader"); - let mut doc = Doc::new("compatibility", "Receipt compatibility").unwrap(); - let observation = doc.expect_eq("nothing", Option::::None, None).unwrap(); - doc.require("private-check", true).unwrap(); - let label = doc - .binding( - "label", - executable_docs::BindingValue::Code("result".into()), - ) - .unwrap(); - doc.paragraph(("The ", label, " is ", observation)).unwrap(); - let resource = doc - .resource( - "download", - Artifact::new(b"result", "text/plain", "txt", "text", "v1").unwrap(), - ) - .unwrap(); - doc.embed(&resource, "Download the result").unwrap(); - let candidate = Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap(); - let mut retained = candidate.clone(); - let mut receipt: serde_json::Value = - serde_json::from_slice(&retained["manifest.json"]).unwrap(); - let checks = receipt["documents"][0]["checks"].as_array().unwrap(); - let nothing = checks - .iter() - .find(|check| check["id"] == "nothing") - .unwrap(); - assert_eq!(nothing.get("actual"), Some(&serde_json::Value::Null)); - assert_eq!(nothing.get("expected"), Some(&serde_json::Value::Null)); - let private = checks - .iter() - .find(|check| check["id"] == "private-check") - .unwrap(); - assert!(private.get("actual").is_none() && private.get("expected").is_none()); - // Additional record fields have always been tolerated. A typed internal - // reader must not make an otherwise valid retained baseline incompatible. - receipt["extension"] = serde_json::json!({"owner": "another consumer"}); - for document in receipt["documents"].as_array_mut().unwrap() { - document["extension"] = serde_json::json!(1); - for check in document["checks"].as_array_mut().unwrap() { - check["extension"] = serde_json::json!(null); - } - for block in document["blocks"].as_array_mut().unwrap() { - block["extension"] = serde_json::json!(false); - for reference in block["references"].as_array_mut().unwrap() { - reference["extension"] = serde_json::json!([]); - } - } - for binding in document["bindings"].as_object_mut().unwrap().values_mut() { - binding["extension"] = serde_json::json!({}); - } - } - for key in ["profiles", "artifacts"] { - for record in receipt[key].as_array_mut().unwrap() { - record["extension"] = serde_json::json!("retained"); - } - } - retained.insert( - "manifest.json".into(), - serde_json::to_vec(&receipt).unwrap(), - ); - store::build(&root, &retained).unwrap(); - store::check(&root, &retained).unwrap(); - store::update(&root, &candidate).unwrap(); - store::check(&root, &candidate).unwrap(); -} - -fn snapshot(root: &Path) -> BTreeMap> { - fn walk(base: &Path, dir: &Path, files: &mut BTreeMap>) { - for entry in fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(base, &path, files); - } else { - files.insert( - path.strip_prefix(base).unwrap().to_str().unwrap().into(), - fs::read(path).unwrap(), - ); - } - } - } - let mut files = BTreeMap::new(); - walk(root, root, &mut files); - files -} - -#[test] -fn check_is_read_only_and_reports_changed_missing_and_extra_files() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "original"); - store::update(&root, &expected).unwrap(); - let saved = snapshot(&root); - store::check(&root, &expected).unwrap(); - assert_eq!(snapshot(&root), saved); - let changed = files("reader", "v1", "changed"); - assert!( - store::check(&root, &changed) - .unwrap_err() - .contains("changed pages/example.md") - ); - let fewer = without_content(); - assert!( - store::check(&root, &fewer) - .unwrap_err() - .contains("extra pages/example.md") - ); - fs::remove_file(root.join("pages/example.md")).unwrap(); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert!(!parent.join(".reader.lock").exists()); -} - -#[test] -fn intended_updates_work_but_retired_files_require_explicit_removal() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let original = files("reader", "v1", "old"); - store::update(&root, &original).unwrap(); - let mut replacement = files("reader", "v1", "new"); - store::update(&root, &replacement).unwrap(); - store::check(&root, &replacement).unwrap(); - replacement = without_content(); - let saved = snapshot(&root); - assert!( - store::update(&root, &replacement) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(snapshot(&root), saved); - fs::remove_file(root.join("pages/example.md")).unwrap(); - store::update(&root, &replacement).unwrap(); - store::check(&root, &replacement).unwrap(); -} - -#[test] -fn incompatible_profiles_audiences_and_schemas_cannot_be_blessed() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let original = files("reader", "v1", "old"); - store::update(&root, &original).unwrap(); - let saved = snapshot(&root); - for (candidate, message) in [ - (files("reader", "v2", "old"), "profiles"), - (files("contributor", "v1", "old"), "audience"), - ] { - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains(message) - ); - assert!( - store::check(&root, &candidate) - .unwrap_err() - .contains(message) - ); - assert_eq!(snapshot(&root), saved); - } - let mut candidate = original.clone(); - candidate.insert( - "manifest.json".into(), - br#"{"schema_version":99,"audience":"reader","profiles":[],"artifacts":[]}"#.to_vec(), - ); - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains("schema") - ); - assert_eq!(snapshot(&root), saved); -} - -fn resource_files(resources: &[(&str, &str, &str)]) -> store::Files { - let mut doc = Doc::new("example", "Example").unwrap(); - doc.require("executed", true).unwrap(); - for (id, producer, profile) in resources { - let resource = doc - .resource_at( - id, - Artifact::new( - id.as_bytes(), - "application/octet-stream", - "bin", - producer, - profile, - ) - .unwrap(), - &format!("assets/{id}.bin"), - ) - .unwrap(); - doc.paragraph(resource.link(id)).unwrap(); - } - Document::render_many(&[doc.finish().unwrap()], Audience::Reader).unwrap() -} - -#[test] -fn adding_and_explicitly_retiring_producers_does_not_change_retained_compatibility() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let first = resource_files(&[("config", "json", "v1")]); - store::update(&root, &first).unwrap(); - let expanded = resource_files(&[("config", "json", "v1"), ("preview", "renderer", "metal")]); - store::update(&root, &expanded).unwrap(); - store::check(&root, &expanded).unwrap(); - let retired = resource_files(&[("preview", "renderer", "metal")]); - assert!( - store::update(&root, &retired) - .unwrap_err() - .contains("explicit removal") - ); - fs::remove_file(root.join("assets/config.bin")).unwrap(); - store::update(&root, &retired).unwrap(); - store::check(&root, &retired).unwrap(); -} - -#[test] -fn retained_resources_cannot_swap_profiles_or_producers() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let first = resource_files(&[("a", "renderer", "metal"), ("b", "renderer", "vulkan")]); - store::update(&root, &first).unwrap(); - let saved = snapshot(&root); - // The aggregate profile set is unchanged, but each retained resource differs. - let swapped = resource_files(&[("a", "renderer", "vulkan"), ("b", "renderer", "metal")]); - assert!( - store::update(&root, &swapped) - .unwrap_err() - .contains("profiles for example/a") - ); - assert_eq!(snapshot(&root), saved); - let producer_change = resource_files(&[ - ("a", "different-renderer", "metal"), - ("b", "renderer", "vulkan"), - ]); - assert!( - store::update(&root, &producer_change) - .unwrap_err() - .contains("profiles for example/a") - ); - assert_eq!(snapshot(&root), saved); -} - -#[test] -fn unsafe_or_conflicting_paths_fail_before_creating_output() { - let (_temp, parent) = directory(); - for unsafe_path in [ - "../escape", - "/absolute", - "a//b", - "a/./b", - "a\\b", - ".ownership.json", - ] { - let root = parent.join("reader"); - let mut candidate = files("reader", "v1", "content"); - candidate.insert(unsafe_path.into(), b"unsafe".to_vec()); - assert!( - store::update(&root, &candidate) - .unwrap_err() - .contains("path") - ); - assert!(!root.exists()); - } - let mut candidate = files("reader", "v1", "content"); - candidate.insert("pages".into(), b"conflict".to_vec()); - assert!( - store::update(&parent.join("reader"), &candidate) - .unwrap_err() - .contains("conflicts") - ); -} - -#[test] -fn unowned_files_and_directories_are_preserved() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - fs::create_dir(&root).unwrap(); - fs::write(root.join("personal.txt"), "keep me").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("ownership") - ); - assert_eq!( - fs::read_to_string(root.join("personal.txt")).unwrap(), - "keep me" - ); - fs::remove_file(root.join("personal.txt")).unwrap(); - fs::remove_dir(&root).unwrap(); - store::update(&root, &expected).unwrap(); - fs::write(root.join("personal.txt"), "keep me").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Unowned") - ); - fs::remove_file(root.join("personal.txt")).unwrap(); - fs::create_dir(root.join("personal")).unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Unowned output directory") - ); - assert!(root.join("personal").is_dir()); -} - -#[test] -fn duplicate_ownership_entries_are_rejected() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - fs::write( - root.join(".ownership.json"), - br#"{"schema_version":1,"files":["manifest.json","manifest.json"]}"#, - ) - .unwrap(); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("Duplicate owned") - ); -} - -#[test] -fn phantom_ownership_entries_cannot_be_checked_updated_or_recovered() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - let marker = root.join(".ownership.json"); - let mut ownership: serde_json::Value = - serde_json::from_slice(&fs::read(&marker).unwrap()).unwrap(); - ownership["files"] - .as_array_mut() - .unwrap() - .push("never-generated.txt".into()); - fs::write(&marker, serde_json::to_vec_pretty(&ownership).unwrap()).unwrap(); - let damaged = snapshot(&root); - - for result in [ - store::check(&root, &expected), - store::update(&root, &expected), - ] { - let error = result.unwrap_err(); - assert!(error.contains("Ownership inventory"), "{error}"); - assert!(error.contains("never-generated.txt"), "{error}"); - assert_eq!(snapshot(&root), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); - } - - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("missing never-generated.txt") - ); - assert_eq!(snapshot(&backup), damaged); - assert!(!root.exists()); -} - -#[test] -fn retirement_requires_agreement_between_manifest_and_ownership_inventory() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - fs::remove_file(root.join("pages/example.md")).unwrap(); - - // Deleting a still-required resource cannot be accepted as retirement. - let damaged = snapshot(&root); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert_eq!(snapshot(&root), damaged); - - // Even genuine retirement must not conceal a contradictory ownership record. - let marker = root.join(".ownership.json"); - let mut ownership: serde_json::Value = - serde_json::from_slice(&fs::read(&marker).unwrap()).unwrap(); - ownership["files"] - .as_array_mut() - .unwrap() - .retain(|name| name != "pages/example.md"); - fs::write(&marker, serde_json::to_vec_pretty(&ownership).unwrap()).unwrap(); - fs::remove_dir(root.join("pages")).unwrap(); - let damaged = snapshot(&root); - assert!( - store::update(&root, &without_content()) - .unwrap_err() - .contains("Ownership inventory differs") - ); - assert_eq!(snapshot(&root), damaged); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 1); -} - -#[test] -fn lock_conflicts_never_remove_someone_elses_lock() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - let lock = parent.join(".reader.lock"); - fs::write(&lock, "other writer").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("lock") - ); - assert!(store::check(&root, &expected).unwrap_err().contains("lock")); - assert_eq!(fs::read_to_string(&lock).unwrap(), "other writer"); -} - -#[test] -fn interrupted_transaction_material_is_not_discarded() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - for suffix in ["staging", "backup"] { - let retained = parent.join(format!(".reader.{suffix}")); - fs::create_dir(&retained).unwrap(); - fs::write(retained.join("evidence.txt"), "inspect first").unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("Interrupted transaction") - ); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("Interrupted transaction") - ); - assert_eq!( - fs::read_to_string(retained.join("evidence.txt")).unwrap(), - "inspect first" - ); - fs::remove_dir_all(retained).unwrap(); - } -} - -#[test] -fn explicit_recovery_restores_only_a_complete_backup_to_an_absent_destination() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "content"); - store::update(&root, &expected).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("destination exists") - ); - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - store::recover_backup(&root).unwrap(); - store::check(&root, &expected).unwrap(); - fs::rename(&root, &backup).unwrap(); - fs::remove_file(backup.join("pages/example.md")).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("missing pages/example.md") - ); - assert!(backup.exists()); - assert!(!root.exists()); -} - -#[test] -fn builds_require_new_destination_and_preserve_existing_candidates() { - let (_temp, parent) = directory(); - let root = parent.join("candidate"); - let expected = files("reader", "v1", "content"); - store::build(&root, &expected).unwrap(); - let saved = snapshot(&root); - assert!( - store::build(&root, &expected) - .unwrap_err() - .contains("already exists") - ); - assert_eq!(snapshot(&root), saved); -} - -#[cfg(unix)] -#[test] -fn symlink_destinations_ancestors_resources_and_transaction_paths_are_rejected() { - use std::os::unix::fs::symlink; - let (_temp, parent) = directory(); - let expected = files("reader", "v1", "content"); - let protected = parent.join("protected"); - fs::create_dir(&protected).unwrap(); - fs::write(protected.join("keep"), "untouched").unwrap(); - let alias = parent.join("alias"); - symlink(&protected, &alias).unwrap(); - assert!( - store::update(&alias.join("reader"), &expected) - .unwrap_err() - .contains("Symlink") - ); - assert!( - store::update(&alias, &expected) - .unwrap_err() - .contains("Symlink") - ); - for suffix in ["lock", "staging", "backup"] { - let link = parent.join(format!(".reader.{suffix}")); - symlink(&protected, &link).unwrap(); - assert!( - store::update(&parent.join("reader"), &expected) - .unwrap_err() - .contains("Symlink") - ); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - fs::remove_file(link).unwrap(); - } - let root = parent.join("reader"); - store::update(&root, &expected).unwrap(); - fs::remove_file(root.join("pages/example.md")).unwrap(); - symlink(protected.join("keep"), root.join("pages/example.md")).unwrap(); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("symlink") - ); - assert_eq!( - fs::read_to_string(protected.join("keep")).unwrap(), - "untouched" - ); -} - -#[test] -fn generation_errors_and_panics_never_publish_any_requested_audience() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let expected = files("reader", "v1", "original"); - store::update(&baseline.join("reader"), &expected).unwrap(); - let saved = snapshot(&baseline); - let args = || ["update", "--audience", "both"].map(str::to_owned); - assert!( - run_cli(args(), &baseline, || Err("failed assertion".into())) - .unwrap_err() - .contains("failed assertion") - ); - assert_eq!(snapshot(&baseline), saved); - assert!( - run_cli(args(), &baseline, || panic!("broken generator")) - .unwrap_err() - .contains("panicked") - ); - assert_eq!(snapshot(&baseline), saved); - assert!(!baseline.join("contributor").exists()); -} - -#[test] -fn invalid_cli_or_overlapping_build_paths_fail_before_execution() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - for args in [ - vec!["unknown"], - vec!["check", "--out", "somewhere"], - vec!["build"], - vec!["update", "--audience", "unknown"], - ] { - let invoked = std::cell::Cell::new(false); - assert!( - run_cli(args.into_iter().map(str::to_owned), &baseline, || { - invoked.set(true); - Err("must not execute".into()) - }) - .is_err() - ); - assert!(!invoked.get()); - } - for output in [&baseline, &baseline.join("candidate"), &parent] { - let invoked = std::cell::Cell::new(false); - let error = run_cli( - vec![ - "build".into(), - "--out".into(), - output.to_str().unwrap().into(), - ], - &baseline, - || { - invoked.set(true); - Err("must not execute".into()) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap")); - assert!(!invoked.get()); - } -} - -#[test] -fn a_case_alias_of_existing_baseline_fails_before_generation() { - let (_temp, parent) = directory(); - let baseline = parent.join("Baseline"); - let alias = parent.join("baseline"); - let expected = files("reader", "v1", "retained bytes"); - store::update(&baseline.join("reader"), &expected).unwrap(); - if !alias.exists() { - // The distinct-directory behavior on case-sensitive hosts is covered by - // the lifecycle test; there is no case alias to exercise in this runner. - return; - } - assert!(same_file::is_same_file(&baseline, &alias).unwrap()); - let saved = snapshot(&baseline); - let output = alias.join("candidate"); - let invoked = std::cell::Cell::new(false); - let error = run_cli( - [ - "build".into(), - "--out".into(), - output.to_str().unwrap().into(), - ], - &baseline, - || { - invoked.set(true); - Ok(vec![document()]) - }, - ) - .unwrap_err(); - assert!(error.contains("overlap")); - assert!(!invoked.get(), "preflight must precede generation"); - assert!(!output.exists()); - assert_eq!(snapshot(&baseline), saved); -} - -fn document() -> executable_docs::Document { - let mut doc = executable_docs::Doc::new("example", "Example").unwrap(); - let value = doc.expect_eq("value", 8080, 8080).unwrap(); - doc.paragraph(("Port ", &value, ".")).unwrap(); - doc.note("Contributor-only explanation").unwrap(); - doc.finish().unwrap() -} - -#[test] -fn both_views_execute_once_and_publish_separate_inventories() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let candidate = parent.join("candidate"); - let invoked = std::cell::Cell::new(0); - run_cli( - vec![ - "build".into(), - "--out".into(), - candidate.to_str().unwrap().into(), - "--audience".into(), - "both".into(), - ], - &baseline, - || { - invoked.set(invoked.get() + 1); - Ok(vec![document()]) - }, - ) - .unwrap(); - assert_eq!(invoked.get(), 1); - assert!(!baseline.exists()); - for audience in ["reader", "contributor"] { - let manifest: serde_json::Value = serde_json::from_slice( - &fs::read(candidate.join(audience).join("manifest.json")).unwrap(), - ) - .unwrap(); - assert_eq!(manifest["audience"], audience); - assert!(candidate.join(audience).join(".ownership.json").exists()); - } -} - -#[test] -fn render_failure_prevents_all_publication() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let doc = document(); - let error = run_cli( - ["update", "--audience", "both"].map(str::to_owned), - &baseline, - || Ok(vec![doc.clone(), doc]), - ) - .unwrap_err(); - assert!(error.to_lowercase().contains("duplicate")); - assert!(!baseline.exists()); -} - -#[test] -fn partial_audience_publication_is_explicit_and_preserves_other_writer() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - fs::create_dir(&baseline).unwrap(); - let lock = baseline.join(".contributor.lock"); - fs::write(&lock, "another writer").unwrap(); - let error = run_cli( - ["update", "--audience", "both"].map(str::to_owned), - &baseline, - || Ok(vec![document()]), - ) - .unwrap_err(); - assert!(error.contains("earlier audiences completed independently: reader")); - assert!(baseline.join("reader/manifest.json").exists()); - assert!(!baseline.join("contributor").exists()); - assert_eq!(fs::read_to_string(lock).unwrap(), "another writer"); -} - -fn edit_manifest(files: &mut store::Files, edit: impl FnOnce(&mut serde_json::Value)) { - let mut manifest: serde_json::Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - edit(&mut manifest); - files.insert( - "manifest.json".into(), - serde_json::to_vec_pretty(&manifest).unwrap(), - ); -} - -#[test] -fn inconsistent_artifact_manifests_never_build_update_or_pass_check() { - let (_temp, parent) = directory(); - let baseline = parent.join("baseline"); - let original = files("reader", "v1", "frozen bytes"); - store::update(&baseline, &original).unwrap(); - let saved = snapshot(&baseline); - let mut candidates = Vec::new(); - let mut changed_bytes = original.clone(); - changed_bytes.insert( - "pages/example.md".into(), - b"different actual bytes".to_vec(), - ); - candidates.push(("stale hash", changed_bytes)); - let mut missing = original.clone(); - missing.remove("pages/example.md"); - candidates.push(("missing file", missing)); - let mut unrecorded = original.clone(); - unrecorded.insert("unrecorded.txt".into(), b"extra".to_vec()); - candidates.push(("unrecorded file", unrecorded)); - - for (name, edit) in [ - ( - "wrong length", - (|manifest: &mut serde_json::Value| { - manifest["artifacts"][0]["bytes"] = 999.into(); - }) as fn(&mut serde_json::Value), - ), - ("wrong hash", |manifest| { - manifest["artifacts"][0]["sha256"] = "0".repeat(64).into(); - }), - ("missing artifact", |manifest| { - manifest["artifacts"].as_array_mut().unwrap().pop(); - }), - ("duplicate path", |manifest| { - let repeated = manifest["artifacts"][0].clone(); - manifest["artifacts"].as_array_mut().unwrap().push(repeated); - }), - ("duplicate document", |manifest| { - let repeated = manifest["documents"][0].clone(); - manifest["documents"].as_array_mut().unwrap().push(repeated); - }), - ("unknown document", |manifest| { - manifest["artifacts"][0]["document"] = "unknown".into(); - }), - ("unsafe declared path", |manifest| { - manifest["artifacts"][0]["path"] = "../outside".into(); - }), - ("unknown artifact kind", |manifest| { - manifest["artifacts"][0]["kind"] = "unknown".into(); - }), - ("profile mismatch", |manifest| { - manifest["profiles"][0]["profile"] = "invented".into(); - }), - ("duplicate profile", |manifest| { - let repeated = manifest["profiles"][0].clone(); - manifest["profiles"].as_array_mut().unwrap().push(repeated); - }), - ("failed check", |manifest| { - manifest["documents"][0]["checks"][0]["passed"] = false.into(); - }), - ("wrong-kind reference", |manifest| { - manifest["documents"][0]["blocks"][0]["references"][0]["kind"] = "value".into(); - }), - ("reader note", |manifest| { - manifest["documents"][0]["blocks"][0]["kind"] = "note".into(); - }), - ] { - let mut candidate = original.clone(); - edit_manifest(&mut candidate, edit); - candidates.push((name, candidate)); - } - for (index, (name, candidate)) in candidates.into_iter().enumerate() { - let fresh = parent.join(format!("candidate-{index}")); - assert!( - store::build(&fresh, &candidate).is_err(), - "build accepted {name}" - ); - assert!(!fresh.exists(), "build wrote a rejected candidate: {name}"); - assert!( - store::update(&baseline, &candidate).is_err(), - "update accepted {name}" - ); - assert!( - store::check(&baseline, &candidate).is_err(), - "check accepted {name}" - ); - assert_eq!( - snapshot(&baseline), - saved, - "rejected {name} changed the baseline" - ); - } -} - -#[test] -fn distinct_paths_cannot_reuse_a_resource_identity() { - let (_temp, parent) = directory(); - let mut candidate = - resource_files(&[("first", "producer", "v1"), ("second", "producer", "v1")]); - edit_manifest(&mut candidate, |manifest| { - let resources: Vec<_> = manifest["artifacts"] - .as_array_mut() - .unwrap() - .iter_mut() - .filter(|record| record["kind"] == "resource") - .collect(); - for resource in resources { - resource["id"] = "same".into(); - } - }); - let error = store::build(&parent.join("candidate"), &candidate).unwrap_err(); - assert!(error.contains("Duplicate manifest evidence identity")); -} - -#[test] -fn corruption_of_retained_bytes_or_backup_cannot_be_approved_as_an_update() { - let (_temp, parent) = directory(); - let root = parent.join("reader"); - let expected = files("reader", "v1", "original"); - store::update(&root, &expected).unwrap(); - fs::write(root.join("pages/example.md"), "corrupted").unwrap(); - let damaged = snapshot(&root); - assert!( - store::check(&root, &expected) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!( - store::update(&root, &expected) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert_eq!(snapshot(&root), damaged); - let backup = parent.join(".reader.backup"); - fs::rename(&root, &backup).unwrap(); - assert!( - store::recover_backup(&root) - .unwrap_err() - .contains("size/hash mismatch") - ); - assert!(backup.exists()); - assert!(!root.exists()); -} diff --git a/crates/doc-harness/tests/store_layout.rs b/crates/doc-harness/tests/store_layout.rs deleted file mode 100644 index a3e5fc2..0000000 --- a/crates/doc-harness/tests/store_layout.rs +++ /dev/null @@ -1,171 +0,0 @@ -use executable_docs::{Audience, Doc, Document, ExportLayout, lifecycle, store}; -use serde_json::Value; -use std::{fs, path::PathBuf}; - -fn directory() -> (tempfile::TempDir, PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - (temp, root) -} - -fn files(page: &str, text: &str) -> store::Files { - let mut document = Doc::new("example", "An independently placed page").unwrap(); - document.require("completed", true).unwrap(); - let output = document.text("output", text).unwrap(); - document - .paragraph(("The observed output is in ", &output, ".")) - .unwrap(); - let layout = ExportLayout::default() - .resources_under("evidence/observations") - .unwrap() - .page("example", page) - .unwrap(); - Document::render_many_with(&[document.finish().unwrap()], Audience::Reader, &layout).unwrap() -} - -fn edit_manifest(files: &mut store::Files, edit: impl FnOnce(&mut Value)) { - let mut manifest: Value = serde_json::from_slice(&files["manifest.json"]).unwrap(); - edit(&mut manifest); - files.insert( - "manifest.json".into(), - serde_json::to_vec_pretty(&manifest).unwrap(), - ); -} - -#[test] -fn custom_nested_exports_build_check_update_and_recover() { - let (_temp, parent) = directory(); - let root = parent.join("independent-publication"); - let original = files("manual/getting-started/README.md", "original observation"); - assert!(!original.contains_key("example.md")); - assert!(original.contains_key("evidence/observations/example/output.txt")); - store::build(&root, &original).unwrap(); - store::check(&root, &original).unwrap(); - - let changed = files("manual/getting-started/README.md", "new observation"); - assert!( - store::check(&root, &changed) - .unwrap_err() - .contains("changed") - ); - store::update(&root, &changed).unwrap(); - store::check(&root, &changed).unwrap(); - - // Recovery must validate the same manifest-defined page layout as publication. - fs::rename(&root, parent.join(".independent-publication.backup")).unwrap(); - store::recover_backup(&root).unwrap(); - store::check(&root, &changed).unwrap(); - assert_eq!( - fs::read_to_string(root.join("evidence/observations/example/output.txt")).unwrap(), - "new observation" - ); -} - -#[test] -fn moving_a_page_requires_explicit_retirement_of_its_old_owned_path() { - let (_temp, parent) = directory(); - let root = parent.join("publication"); - let old_page = "manual/start/README.md"; - let original = files(old_page, "same evidence"); - store::build(&root, &original).unwrap(); - let retained = lifecycle::read_tree(&root).unwrap(); - let moved = files("handbook/entry.md", "same evidence"); - - assert!( - store::update(&root, &moved) - .unwrap_err() - .contains("explicit removal") - ); - assert_eq!(lifecycle::read_tree(&root).unwrap(), retained); - fs::remove_file(root.join(old_page)).unwrap(); - store::update(&root, &moved).unwrap(); - store::check(&root, &moved).unwrap(); - assert!(!root.join(old_page).exists()); - assert!(root.join("handbook/entry.md").is_file()); -} - -#[test] -fn configurable_paths_do_not_weaken_page_identity_or_inventory_validation() { - let (_temp, parent) = directory(); - let baseline = parent.join("retained"); - let page = "manual/getting-started/README.md"; - let original = files(page, "frozen evidence"); - store::build(&baseline, &original).unwrap(); - let retained = lifecycle::read_tree(&baseline).unwrap(); - let mut candidates = Vec::new(); - - let mut missing = original.clone(); - missing.remove(page); - edit_manifest(&mut missing, |manifest| { - manifest["artifacts"] - .as_array_mut() - .unwrap() - .retain(|record| record["kind"] != "page"); - }); - candidates.push(("missing page", missing)); - - let mut duplicate = original.clone(); - duplicate.insert("manual/extra.md".into(), original[page].clone()); - edit_manifest(&mut duplicate, |manifest| { - let mut record = manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .find(|record| record["kind"] == "page") - .unwrap() - .clone(); - record["path"] = "manual/extra.md".into(); - manifest["artifacts"].as_array_mut().unwrap().push(record); - }); - candidates.push(("duplicate page identity", duplicate)); - - for (name, field, value) in [ - ("unknown document", "document", "other-document"), - ("wrong MIME", "mime", "text/plain"), - ("unsafe path", "path", "../outside.md"), - ("reserved path", "path", ".ownership.json/page.md"), - ] { - let mut candidate = original.clone(); - edit_manifest(&mut candidate, |manifest| { - let record = manifest["artifacts"] - .as_array_mut() - .unwrap() - .iter_mut() - .find(|record| record["kind"] == "page") - .unwrap(); - record[field] = value.into(); - }); - candidates.push((name, candidate)); - } - let mut collision = original.clone(); - collision.insert( - "manual".into(), - b"cannot be a file and a directory".to_vec(), - ); - candidates.push(("file and directory collision", collision)); - let mut corrupt = original.clone(); - corrupt.insert(page.into(), b"changed without a matching receipt".to_vec()); - candidates.push(("stale page hash", corrupt)); - - for (index, (name, candidate)) in candidates.into_iter().enumerate() { - let fresh = parent.join(format!("rejected-{index}")); - assert!( - store::build(&fresh, &candidate).is_err(), - "build accepted {name}" - ); - assert!(!fresh.exists(), "build wrote invalid {name}"); - assert!( - store::check(&baseline, &candidate).is_err(), - "check accepted {name}" - ); - assert!( - store::update(&baseline, &candidate).is_err(), - "update accepted {name}" - ); - assert_eq!( - lifecycle::read_tree(&baseline).unwrap(), - retained, - "{name} changed retained evidence" - ); - } -} diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md index 433fb3e..c4e1d99 100644 --- a/docs/architecture/executable-documents.md +++ b/docs/architecture/executable-documents.md @@ -1,22 +1,25 @@ # Executable documents and the N3 adapter -The maintained `executable-docs` SDK separates document composition and artifact -publication from the application being documented. N3 remains one application -crate. The SDK lives in [crates/doc-harness](../../crates/doc-harness/README.md), -and [crates/doc-example-config](../../crates/doc-example-config/README.md) provides -a real CLI consumer and an integration with the existing serde_json library. -Neither SDK use nor those examples requires N3's renderer, UI stack, website, -or agent skill. - -This is a local, experimental API with `publish = false`. The implementation is -maintained here so the same source can support N3 and other consumers before a -future repository move. No registry publication or release is implied. +The [lgtmd SDK](https://github.com/lgtmd/lgtmd) separates document +composition and artifact publication from the application being documented. +N3 remains one application crate and consumes lgtmd from an exact Git revision. +The SDK, its Node bridge, and portable Rust/React examples are maintained in the +lgtmd repository. N3 owns its application adapter, replay, renderer, scenarios, +and guide artifacts here. Neither SDK use nor its examples requires N3's renderer, +UI stack, website, or agent skill. + +The SDK remains experimental and unpublished. Moving its source establishes +independent repository ownership; it does not establish a public release or a +stable API. A future registry dependency can replace the Git pin after the +package passes its release gates. ## The shared contract -An author writes a user explanation while ordinary Rust code executes behavior -and checks important promises. The completed document owns its prose, checked -observations, resources, and optional contributor notes. Reader and Contributor +An author writes a user explanation while ordinary Rust or Node code executes +behavior and checks important promises. Explanation-only pages are valid. +`observe` freezes a value without implying a check; successful named checks and +host outcomes remain distinct. The completed document owns its prose, +observations, checks, resources, and optional contributor notes. Reader and Contributor exports reuse those frozen results; rendering another audience does not rerun the application or recapture resources. @@ -28,7 +31,7 @@ correct application behavior from an artifact's existence or a declared MIME type. Meaningful assertions, producer metadata, fixtures, runtime drivers, review, and documentation organization remain the consumer's responsibility. -The [SDK README](../../crates/doc-harness/README.md) is the API and manifest +The [SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) is the API and manifest reference. N3's [documentation pipeline](documentation-pipeline.md) owns product replay, timing, captures, and renderer-specific evidence. This document records the boundary between them. @@ -86,7 +89,7 @@ paths need distinct ASCII components beyond any shared ancestors. Case-only or Unicode-only distinctions require preexisting separate directories rather than guessing a filesystem's alias rules. Unicode parent directories and later path components remain supported. -The [configured SDK example](../../crates/doc-harness/examples/configured.rs) +The [configured SDK example](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/examples/configured.rs) exercises a source-only host, external baselines, nested pages, renamed audience directories, private resources, and preservation of unrelated files. This boundary does not add a configuration file format, plugin loader, or repository abstraction. @@ -191,14 +194,17 @@ review. It does not approve product behavior, publication, or a release. ## Verification and remaining validation -`just doc-framework-test` delegates to the SDK-owned -[verification entry point](../../crates/doc-harness/dev/verify.py): scoped -formatting, Clippy, tooling regressions, SDK and consumer tests, doctests, and API -docs without N3's GPU harness. Each consumer checks retained Reader and -Contributor outputs. `just doc-framework-smoke --offline` copies those packages -into a disposable minimal workspace, resolves its own temporary lockfile, and -verifies the declared Rust 1.95 minimum independently of N3's dependencies and -patches. No permanent second workspace is introduced. +N3's `just verify` checks its formatting, Clippy, tooling, full Rust suite, +measurement contracts, and exact guide replay on the native renderer. An SDK +revision update goes through this same gate; changes to its API must remain +explicit in the N3 adapter and scenarios. + +The SDK owns its independent +[verification workflow](https://github.com/lgtmd/lgtmd/blob/main/AGENTS.md), +including portable sources, Rust/Node tests, its own examples, and installed +Cargo/npm consumers. Those gates run in the lgtmd repository rather than through +N3's application commands or CI. They supplement consumer verification and do +not establish N3 behavior or renderer compatibility. The SDK's static preview validates the complete export receipt before displaying it. Page identity comes from that inventory rather than file extensions; @@ -216,3 +222,43 @@ maintainer adoption. Validation with independent project owners, the remaining future work. The framework detects encoded behavioral and artifact drift; prose clarity, meaningful coverage, visual legibility, and physical interaction still need human review. + +## Node and web integration + +[packages/lgtmd](https://github.com/lgtmd/lgtmd/blob/main/packages/lgtmd/README.md) authors documents in Node 24 ESM; +[crates/lgtmd-node](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd-node/Cargo.toml) is its coarse native bridge. +One serialized request per compile/publication carries schema-v1 frozen input to +the same Rust validation, Markdown exporters, runner and owned store. The wire +contains complete ordered content and resource snapshots, never process-local +handle owners or application callbacks. Rust transport round trips preserve +existing native exports, including literal Markdown versus typed parts. Built-in +JSON resources use one Rust encoder, including those registered from Node. + +The [three React/Vite examples](https://github.com/lgtmd/lgtmd/blob/main/examples/lgtmd-web/README.md) exercise prose-only +authoring, application-owned async preparation/cleanup, supplied context, +observations, native checks, private notes/resources, JSON and CSV downloads, and +Playwright screenshot evidence. The React app uses ordinary Vite development and +production builds, with a project-owned HTTP API and isolated test workspaces. +Documentation captures run the built app through Vite preview on separate test +ports; the development servers can remain open. +The browser bundle imports no SDK code, and the examples import no N3 application +code. The optional +adapter extends a project's existing Playwright fixtures and assertions. It +collects candidate attachments and publishes only after the real runner process +and guarded reporters complete successfully. It initially supports one project, +no retries/repetition/sharding, and clean passed outcomes for all discovered tests. +Application state, services, seeds and DOM query correctness remain project-owned. + +WASM was probed with the same compiler input and produced equal exports, but its +filesystem store returned an unsupported-platform error. The selected native +route retains the existing strict filesystem contract. It requires platform +binaries in npm tarballs; installation performs no Rust build. Node 24 ESM on +macOS arm64 is the executed local scope. Linux x64 has CI wiring, not a completed +runtime claim. A full viewer and browser-hosted SDK are deferred. + +The SDK repository owns explicit browser setup and its Node/Chromium verification +workflow. It checks the native bridge, authoring and failure semantics, real +browser replay, exact repeated exports, and clean Cargo/npm consumers independently +of N3. The npm consumer installs with Rust absent from PATH and imports the core +before installing the optional Playwright peer. N3's existing browser editor and +its native documentation adapter retain their own host policies and checks. diff --git a/docs/development.md b/docs/development.md index 6cf9e57..f240a50 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,9 +1,9 @@ # Developing N3 The application is a single Rust package named `n3`, with private implementation -modules. The workspace also maintains the portable `executable-docs` SDK and its -consumer examples under `crates/`; the root package remains the default Cargo -target. Read [AGENTS.md](../AGENTS.md) for development principles and iteration +modules. Its documentation adapter consumes the separately maintained +[lgtmd SDK](https://github.com/lgtmd/lgtmd) at an exact Git revision. +SDK sources and portable examples live in that repository. Read [AGENTS.md](../AGENTS.md) for development principles and iteration guidance. [Testing](../TESTING.md) indexes verification workflows, reference suites, and benchmark resources. Start at [the architecture map](architecture/architecture.md) and [the milestone review](architecture/milestone-review.md). The old viewer-only @@ -422,7 +422,7 @@ navigation adapters, and scene renderer. Assertions verify the intended outcome. The portable SDK owns document composition, frozen evidence, and shared artifact publication; it does not own N3's input or renderer. See [executable documents](architecture/executable-documents.md) for the maintained -boundary and [the SDK README](../crates/doc-harness/README.md) for its API. +boundary and [the SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) for its API. ```sh just docs @@ -501,20 +501,20 @@ Finder delivery, macOS dialogs, or artistic approval. contract using Python's standard library. It also runs as part of `just verify`; it does not replace the Rust replay and exact artifact checks. -For focused framework work, run `just doc-framework-test`. The SDK-owned workflow -checks scoped Rust formatting, Clippy, tooling, SDK and consumer tests, doctests, -and API documentation without starting N3's renderer. This gate also runs inside -`just verify`. Run `just doc-framework-smoke --offline` to verify a disposable -standalone copy on the declared Rust 1.95 minimum, once that toolchain and the -portable dependencies are cached. Its fresh temporary lockfile excludes N3's -dependencies, patches, and workspace configuration. See the -[SDK development workflow](../crates/doc-harness/README.md#develop-and-verify-the-portable-packages) -for direct commands and cache handling. -The [consumer README](../crates/doc-example-config/README.md) documents direct Cargo -commands, package-local baselines, and candidate review. These examples exercise -two integration shapes; the serde_json example is our use of an existing library, -not adoption by its maintainers. N3 adapter or scenario changes still require -the native replay and `just verify`. +SDK authoring, compiler/store work, and its portable Rust/React examples are +maintained and verified in the +[lgtmd repository](https://github.com/lgtmd/lgtmd). Follow its contributor +instructions for SDK setup, independent package checks, and Node/Chromium gates. +N3 does not install or run the SDK's web example during application verification. +The N3 app's `web/` host remains a separate consumer boundary. + +N3 pins the SDK's full Git revision in `Cargo.toml` and retains its resolved +source in `Cargo.lock`. Updating that revision requires `just verify`, including +native replay and exact guide checks; portable SDK checks alone cannot establish +N3 compatibility. Cargo must fetch the pinned source and dependencies before an +offline run. Keep the dependency reproducible without sibling checkout discovery +or a developer-specific path. A registry dependency is deferred until an +explicitly authorized SDK release is ready. ## User settings diff --git a/justfile b/justfile index 04cdd11..ee71417 100644 --- a/justfile +++ b/justfile @@ -180,15 +180,6 @@ lint: test *args: cargo test --locked "$@" -# Verify the maintained documentation SDK, consumers, examples, and exact baselines. -# Default application build/run/test routing remains on N3. -doc-framework-test: - python3 crates/doc-harness/dev/verify.py - -# Verify a disposable portable workspace, including the declared Rust minimum. -doc-framework-smoke *args: - python3 crates/doc-harness/dev/verify.py --standalone --toolchain 1.95.0 "$@" - # Open the local guide preview, check it, or intentionally regenerate it. docs mode="serve" *args: #!/usr/bin/env bash @@ -248,4 +239,4 @@ test-metal: cargo test --locked documentation::capture::tests:: # Native development and pre-push checks. CI infrastructure is opt-in. -verify: fmt-check lint tools-test doc-framework-test test benchmark-rust-test +verify: fmt-check lint tools-test test benchmark-rust-test diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md index b3baed1..defab87 100644 --- a/rfcs/0003-executable-documentation-framework.md +++ b/rfcs/0003-executable-documentation-framework.md @@ -1,15 +1,17 @@ # RFC 0003: A reusable executable documentation framework -Status: **Implemented locally — experimental SDK and N3 bridge; independent adoption and release remain open** +Status: **Implemented and extracted — experimental SDK and N3 bridge; independent adoption and release remain open** Date: 2026-10-04 -Incubate this work inside N3, with an eventual move to a dedicated repository. -The user authorized local implementation after reviewing the initial plan. The -maintained SDK and representative integrations now live in this repository; -[the architecture contract](../docs/architecture/executable-documents.md) records -the implemented boundary. Broader design sketches below remain proposals where -they exceed that boundary. The provisional package is unpublished. +The framework was incubated inside N3 and is now maintained in the dedicated +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact +Git revision and retains its application adapter, replay, renderer, and guide +artifacts. [The architecture contract](../docs/architecture/executable-documents.md) +records the current boundary. The findings and development commands below are a +historical record of incubation; broader sketches remain proposals where they +exceed the implemented contract. lgtmd remains unpublished, and its current +repository owns SDK development and verification. Authoring requirements: concise, honest authoring and scenarios that write the document's text, resources, and explicit contributor notes themselves. The primary @@ -679,7 +681,7 @@ infrastructure, publication, and the repository move remain deferred. The local implementation now includes: -- [An unpublished SDK](../crates/doc-harness/README.md) with Rust-authored prose, +- [An unpublished SDK](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) with Rust-authored prose, checked values, observed bindings, frozen resources, contributor notes, strict audience filtering, a structured manifest, and a project-owned runner. - One filesystem lifecycle used by the SDK and N3. Dedicated trees preserve N3's @@ -695,7 +697,7 @@ The local implementation now includes: format supports private prose notes and public captured media; private captured resources fail before publication. Full SDK exports support contributor-only resources and exclude their files and metadata from the reader bundle. -- [Two non-GUI integrations](../crates/doc-example-config/README.md): a real +- [Two non-GUI integrations](https://github.com/lgtmd/lgtmd/blob/main/crates/doc-example-config/README.md): a real configuration CLI and the existing serde_json library's public parsing/error/ serialization APIs. The latter is our integration, not independent maintainer adoption or upstream documentation. diff --git a/rfcs/0004-lgtmd-web-incubation.md b/rfcs/0004-lgtmd-web-incubation.md new file mode 100644 index 0000000..42322b5 --- /dev/null +++ b/rfcs/0004-lgtmd-web-incubation.md @@ -0,0 +1,345 @@ +# RFC 0004: Incubate lgtmd's Rust and web contracts together + +Status: **Implemented and extracted; release deferred** + +Date: 2026-10-13 + +The portable Rust SDK and TypeScript integration were incubated directly in N3, +without a submodule, while N3 remained a real consumer. The independently verified +SDK, Node bridge, Rust/React examples, and development harness now live in the +[lgtmd repository](https://github.com/lgtmd/lgtmd). N3 consumes an exact +Git revision and retains its own adapter, replay, renderer, and guide artifacts. + +The preferred eventual N3 integration remains a crates.io dependency once the +package passes its release gates. Extraction and dependency pinning do not +publish a package or authorize a release. The incubation findings and original +sequence below preserve the reasoning and historical verification evidence; +SDK-owned development commands now run in the lgtmd repository. + +## Implementation record + +- `crates/lgtmd` now permits explanation-only documents, separate frozen + observations, and typed code resource views. N3 retains its own nonempty replay + fact requirement and unchanged guide artifacts. +- Schema-v1 frozen input and coarse compilation live in `lgtmd::transport`. + Native round trips preserve exports. JSON resources use a shared Rust encoder, + including integer-like keys and fractional numbers registered from Node. +- `packages/lgtmd` supplies Node 24 ESM authoring, native assertion wrappers, + project-owned async preparation/cleanup, supplied context, and optional + `lgtmd/playwright`. Ordinary callback return values are ignored; assertions + remain explicit. Tagged Markdown automatically separates complete blocks. +- The selected bridge is `crates/lgtmd-node`. A WASM probe compiled the same + prose/notes/JSON/image input and produced equal exports, but its filesystem + publication failed as unsupported. The native route reuses the existing strict + Rust store rather than implementing another filesystem contract in Node. +- The Playwright adapter requires a sole guarded `docsReporter`, a completed + receipt, and successful runner-process exit before publishing. Imported custom + reporter instances are supported; built-in reporter-name delegation, retries, + repeatEach greater than one, shards, and multiple/dependency projects are not. +- [Three complete React/Vite examples](https://github.com/lgtmd/lgtmd/blob/main/examples/lgtmd-web/README.md) include all project + setup, cleanup, application code, prose, observations, checks, downloads, + screenshots and contributor notes. React components and ordinary Vite scripts + model a real developer application; lgtmd is a development dependency. Browser + captures use the production bundle on isolated ports. No N3 runtime is required. +- The independent web gate passed on Rust 1.95, Node 24.14.0 and macOS arm64, + including real Chromium replay and exact repeated exports, Cargo packaging and + an extracted consumer, and npm installation/core/Playwright execution with + Rust absent from PATH. The final gate passed all 51 Node tests. Linux x64 CI + is configured but has not run here. +- Full native N3 verification passed against both the working SDK and its actual + packaged source in a disposable N3 copy, without authoring changes: 954 tests + passed, one pre-existing test remained ignored, and the required measurement + and real-renderer parity checks passed. Exact guide and consumer baselines are + unchanged. This rehearses package-content compatibility; registry installation + remains unverified until an explicitly authorized release. + +The web example gate builds a fresh temporary candidate and checks a second +execution against it. This establishes repeated-run determinism, not a retained +visual regression baseline or cross-platform pixel identity. The separately +reviewed local Reader/Contributor trees and screenshots are disposable evidence; +projects choose and explicitly accept their own baselines using `update`. + +See the [implemented architecture](../docs/architecture/executable-documents.md#node-and-web-integration) +and [Node SDK contract](https://github.com/lgtmd/lgtmd/blob/main/packages/lgtmd/README.md). The original sequence below +records the reasoning and later extraction/release gates. + +## Outcome and rationale + +Incubation exercised major authoring, compiler, and host integration changes +through both Rust and JavaScript consumers. Extraction changes manifests, +dependency location, and development wiring while retaining N3's authoring API +and guide output. Future SDK development proceeds in its own repository, with +N3 compatibility checked explicitly when updating the pinned dependency. + +Readiness applies to a declared initial support scope. Incubation cannot promise +that no future breaking change will ever be needed. Package contents, clean +installation, supported runtimes, diagnostics, and compatibility are explicit +exit gates; moving files alone does not establish production readiness. + +[RFC 0003](0003-executable-documentation-framework.md) records the existing Rust +framework. The [implemented architecture](../docs/architecture/executable-documents.md) +and [SDK README](https://github.com/lgtmd/lgtmd/blob/main/crates/lgtmd/README.md) remain authoritative for current behavior. + +## Initial support scope + +- Rust authoring through `lgtmd`, with N3 and the existing CLI/library consumers. +- TypeScript and JavaScript authoring from Node.js, initially on the repository's + Node 24 development baseline. Pin actual package/tool versions when implemented. +- One optional Playwright Test integration, composed with a project's own test + fixtures, assertions, configuration, and browser lifecycle. +- Reader and Contributor Markdown/resource exports through the same Rust + compiler, with explicit build, check, and baseline acceptance behavior. + +The first web integration documents a web application from Node. Running the SDK +inside the application browser is a separate support target. The framework must +also work with supplied context and ordinary async code without Playwright. + +The project owns preparation, mock state, seeds, services, credentials, APIs, and +cleanup. An optional preparation callback returns context and cleanup; it carries +no assumptions about a database, frontend framework, server count, or deployment. +Application-owned fixtures may provide that context instead. DOM queries remain +subject to application changes, and the author must assert the behavior described. + +## Proposed source layout + +Create directories only when their implementation lands: + +```text +crates/lgtmd/ Rust SDK and shared document processing +crates/doc-example-config/ Existing portable Rust consumers and baselines +crates/lgtmd-node/ Selected coarse compiler/store host bridge +packages/lgtmd/ TypeScript SDK, Node host, optional Playwright adapter +examples/lgtmd-web/ Small independent web fixture and authored documents +fixtures/lgtmd/ Shared semantic inputs, exports, and failure cases +``` + +Keep N3's existing `web/` devoted to the N3 application. Use a private npm workspace +for the SDK and example when both exist, with explicit package scripts and a +lockfile. Its dependencies serve this incubation; the application remains Rust. +The Playwright adapter can be an optional `lgtmd/playwright` entry point with a +peer dependency rather than a requirement for every SDK consumer. + +If a native bridge proves preferable, create only the host package it actually +requires instead of maintaining an unused WASM package. Keep Rust processing in +the existing crate unless a demonstrated dependency boundary requires a split. + +Public APIs receive content, context, paths, and options explicitly. They must +not discover an N3 checkout or assume these source directories. Repository-only +verification may know the payload layout and must prove it in a disposable +independent workspace, following the existing Rust standalone gate. + +## Three small documents that exercise the contract + +Use one neutral example project. Existing interface sketches are design input; +their declarations and hand-authored previews are not runtime evidence. + +| Document | What the author writes | What it must establish | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Explain record names | Ordinary paragraphs, headings, a reused prose helper, and a targeted contributor note. | Useful documentation can exist without an executed check; it makes no verification claim. | +| Describe values and resources | Application-owned async preparation, an observation, an explicit assertion, JSON shown as code and linked as a download, and a private text resource. | Values and buffers freeze at registration; preparation and cleanup have honest failure semantics; non-image resources are first class. | +| Filter records in a browser | Existing Playwright fixtures and assertions, filter/clear interactions, a screenshot, prose, and a note on the illustrated passage. | Documentation adds to a normal browser test without owning the application or requiring action wrappers. | + +Start with a tiny deterministic records fixture whose data is supplied by the +example. A production backend is unnecessary to demonstrate the contract. +Include an already-prepared context path and ordinary helper reuse in these +documents, rather than adding more demo applications. + +Implemented authoring shape; the complete runnable example includes setup and finalization: + +```ts +test("Filter records", async ({ page, doc }) => { + doc.page({ id: "filter-records", title: "Find a record" }); + const search = page.getByRole("searchbox", { name: "Search records" }); + const results = page.getByRole("list", { name: "Records" }); + + await search.fill("Alpha"); + await expect(results.getByRole("listitem")) + .toHaveText(["Alpha contract", "Alpha renewal"]); + + const image = doc.assets.image("matches", await results.screenshot()); + const explanation = doc.md` + Search by name to narrow the list. + + ${image.embed("The two matching records")} + `; + doc.noteOn(explanation)`The project fixture owns the supplied records.`; + + await search.fill(""); + await expect(results.getByRole("listitem")) + .toHaveText(["Alpha contract", "Alpha renewal", "Beta proposal"]); +}); +``` + +The runnable example must include the test extension, navigation/setup fixture, +reporter or finalizer configuration, and application implementation omitted from +this sketch. Review the complete authoring cost, including helpers and fixture +configuration. Avoid IDs on every paragraph, duplicate resource declarations, +mandatory wrapping of existing assertions, or parallel prose templates. + +## Contracts to settle during incubation + +### 1. Native authoring and execution + +Rust and TypeScript keep idiomatic APIs over the same document semantics. Prose +segments, typed interpolations, resource presentations, and targeted notes retain +their meaning across the language boundary. Ordinary functions provide reuse. +Specify indentation handling, escaping, block placement, and reference failures +through examples before freezing the TypeScript surface. + +Separate authored explanation, frozen observation, explicitly executed check, +and host run outcome. A passed browser test must not manufacture an SDK check. +Named check wrappers are optional and must preserve native failures, including a +failure caught later by author code. Unwrapped assertions remain host outcomes. + +Before incubation, `Doc::finish` required successful checks and observations +were coupled to equality checks. The implementation deliberately separates those +semantics, with matching tests in both languages. N3 retains its stricter +behavioral requirements in its adapter. +Do not add fake `require(true)` calls to make generic documents pass. + +### 2. Frozen input and one compiler operation + +Use one coarse operation, conceptually +`compile(frozenDocuments, exportOptions) -> exports and diagnostics`. +Native SDKs execute application code and freeze its results before calling it. +The compiler receives no browser object, callback, promise, or application seed. + +Define a versioned input containing complete ordered content, typed references, +observations/check outcomes, notes, document/variant identities, immutable +resources, and useful source context. Current Rust handle ownership is +process-local; transport identity must be explicit and validated. Keep literal +Markdown and interpolated values distinguishable. Specify numeric and JSON +limits, null versus absence, invalid values, byte ownership, and unknown-version +errors rather than relying on lossy serialization defaults. + +Both language paths use the same Rust validation and export logic. A private +Rust model can evolve behind the versioned boundary. Do not expose Rust struct +layout or reconstruct authored content from the current inventory manifest. +Compare identical canonical fixtures across both frontends. + +Complete Markdown pages, resources, and their receipts are sufficient for this +milestone. The canonical interactive viewer remains future work. Its eventual +structured output contract is not claimed stable by this milestone; preserve +document structure internally so another exporter can be added without changing +how authors write their documents. + +### 3. Host lifecycle and acceptance + +Authoring may compile a preview before cleanup, but accepting a successful output +requires the relevant host run to finish successfully. Standalone preparation +owns partial failure before returning context; after successful acquisition, +framework-managed cleanup runs once. Preserve primary and cleanup errors together. +An adapter using project fixtures leaves their cleanup to the existing runner. + +Playwright has dependency-ordered fixture teardown and distinct test/worker +lifetimes. Use its fixture and reporter extension points, with regressions proving +when output becomes eligible. A doc fixture's own teardown alone is insufficient +proof that the entire run succeeded. Relevant worker/global teardown errors, +timeouts, interruption, and reporter failures must also block acceptance. +Prove the final success signal against the completed runner process. Candidate +staging plus a post-process finalizer may be necessary; one reporter callback +cannot establish that every other reporter completed successfully. Select and +test that mechanism during the binding and real-execution stages below. +See [fixture execution order](https://playwright.dev/docs/test-fixtures#execution-order) +and the [reporter lifecycle](https://playwright.dev/docs/api/class-reporter). + +Identify document, project variant, and execution attempt separately. Worker or +retry arrival order must not determine which evidence wins. Start the example +with one project and retries disabled; require an explicit rejection of unsupported +modes. Before broader support, test collision handling and final-attempt policy. +A retry success must retain its flaky status rather than becoming a clean first +pass; [Playwright exposes that distinction](https://playwright.dev/docs/test-retries#retries). +Transient run IDs and host diagnostics must not introduce nondeterminism into +canonical document bytes. + +### 4. Resources, privacy, and publication + +Freeze mutable values and resource bytes when registered. Reader and Contributor +exports reuse one completed run. Private notes and exclusively private resources +must stay out of Reader text, resource files, paths, and metadata; adding notes +must preserve Reader anchors and bytes. Existing check identifiers are public +metadata even when their values are hidden. Keep that policy explicit rather +than silently claiming every evidence identifier is private. + +Configuration selects output roots and layouts. Build a fresh review candidate; +check compares without accepting; update is explicit. Preserve strict ownership, +path separation, failure recovery, and producer/profile compatibility. Reuse the +Rust storage implementation where the selected host permits it; otherwise prove +the Node host against the same filesystem contract before claiming parity. +Publishing multiple audiences is currently not one atomic transaction. + +Use fixed resource bytes for cross-language conformance. Browser screenshots have +their own pinned capture environment and explicit reviewed baseline. Do not claim +cross-platform pixel identity, add hidden tolerances, or weaken N3's exact checks. + +## Binding and tooling decision + +Evaluate WASM first for pure processing, using a real frozen document with prose, +notes, JSON, and image bytes. Compare a coarse native bridge if startup, resource +transfer, diagnostics, packaging, or filesystem lifecycle makes it preferable. +Choose one initial shipping route behind the TypeScript facade. Neither route +should require crossing a process/binding boundary for every paragraph. + +WASM has target-specific deployment and loading behavior; a Node integration does +not establish browser compatibility. Use the +[wasm-bindgen deployment guide](https://wasm-bindgen.github.io/wasm-bindgen/reference/deployment.html) +when implementing the probe. Include actual host publication in the assessment; +a pure compilation demo alone does not settle the installation or storage design. + +Local development may build Rust artifacts. Installing the eventual npm package +must not require a Rust compiler. Pin the supported Node/module format and browser +test dependencies. Make browser/dependency installation an explicit setup step; +checks should not silently fetch a browser or depend on Docker. + +## Implementation sequence and acceptance gates + +1. **Authoring examples and semantics.** Make the three complete source examples + reviewable, including setup and cleanup. Agree on explanation-only pages, + observations, named checks, interpolation, and errors. Measure full authoring + overhead. No production packaging or broad framework support is needed here. +2. **Shared model and binding.** Implement the minimal versioned frozen input, + coarse compilation boundary, and selected bridge. Exercise both Rust and + TypeScript through common conformance cases. Preserve N3 native guide output. +3. **Real web execution.** Run the three examples, compose with real Playwright + fixtures, and prove post-run acceptance. Failure cases include mutable inputs, + foreign/missing references, bad schema, swallowed check failure, teardown + failure, timeout, and unsupported retry/variant configuration. Inspect the + generated prose, stills, and contributor notes in a browser preview. +4. **Independent and packaged verification.** Copy only the portable Rust/JS + sources, examples, conformance fixtures, and tooling to a temporary workspace. + Resolve its own dependencies and run it without N3 source or its renderer. + Run clean consumers against a Cargo package and npm tarball, exercising both + supported entry points and shipped binding assets. The npm consumer check + runs with Rust unavailable. Document the actual supported platform matrix. +5. **Extraction rehearsal and release readiness.** Use the packaged Rust source + in a disposable N3 checkout without changing its authoring code; run native + verification and exact guide comparison. Review licenses, package metadata, + installation, and version/compatibility policy. Then move the payload to + lgtmd, verify it there, and request authorization for an exact release version. + After publication, N3 can switch to crates.io and repeat its locked checks. + +Retain the existing `just doc-framework-test`, standalone Rust smoke, and N3 +`just verify` gates. Add an explicit portable web gate and setup documentation +when implemented, with a CI lane supplying the pinned browser environment. Run +the same Node/browser checks locally; the independent gate must not launch N3. +Optional modes cannot silently reduce the required checks for their declared scope. + +Cargo's packaging workflow checks the archive and builds from its packaged +contents; use that alongside installed-consumer tests, as described in the +[Cargo package reference](https://doc.rust-lang.org/cargo/commands/cargo-package.html). +Before publication, the N3 rehearsal establishes package-content compatibility; +only the later registry installation verifies actual crates.io consumption. + +## Deferred work and remaining decisions + +Defer a full canonical viewer, browser-hosted SDK execution, broad runner adapters, +backend orchestration, automatic seeding conventions, cursor/key compositing, +AI-assisted scenarios, Git/image diffs, and a public plugin protocol. Keep these +possible through clear host/content boundaries, without reserving speculative +APIs or implementing a framework around hypothetical extensions. + +The examples and probe have settled the initial TypeScript signatures, wire +encoding, native bridge, shared Rust publication, and strict single-attempt +Playwright scope. Broader platform verification, distributed release binaries, +version policy, and the initial release version remain later work. The intended exit is direct published-package +consumption from N3; use a submodule only if evidence creates a concrete need. diff --git a/rfcs/README.md b/rfcs/README.md index de7ba56..f8db9f2 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -34,8 +34,9 @@ that contain no useful reasoning do not need an RFC. ## Index -| RFC | Status | Topic | -| -------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------ | -| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | -| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | -| [0003](0003-executable-documentation-framework.md) | Implemented locally | A reusable Rust-first documentation SDK, evidence contract, and staged extraction from N3. | +| RFC | Status | Topic | +| -------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- | +| [0001](0001-paired-selection-in-2d.md) | Deferred | Pair corresponding front/back vertices during selection in an aligned 2D view. | +| [0002](0002-authored-scenes.md) | Draft | N3-owned scenes, editor/history boundaries, migration, and interchange feasibility. | +| [0003](0003-executable-documentation-framework.md) | Implemented and extracted | A reusable Rust-first documentation SDK and N3 adapter, maintained in separate repositories. | +| [0004](0004-lgtmd-web-incubation.md) | Implemented and extracted | Rust/web incubation completed; N3 consumes an exact lgtmd Git revision until registry release. | diff --git a/src/documentation/artifacts.rs b/src/documentation/artifacts.rs index 0016fdc..b77c511 100644 --- a/src/documentation/artifacts.rs +++ b/src/documentation/artifacts.rs @@ -1,6 +1,6 @@ //! Strict generated-tree ownership, bindings, validation and publication. use super::*; -use executable_docs::{ +use lgtmd::{ Audience, lifecycle::{self, Ownership}, }; @@ -414,7 +414,7 @@ mod renderer_tests { } assert!(candidate_destination(&repository.join("Cargo.toml"), &repository).is_err()); // This is a read-only preflight: the fresh destination remains absent. - let fresh = repository.join(".cache/executable-docs-never-created-by-preflight"); + let fresh = repository.join(".cache/lgtmd-never-created-by-preflight"); assert_eq!(candidate_destination(&fresh, &repository).unwrap(), fresh); assert!(!fresh.exists()); } diff --git a/src/documentation/guide.rs b/src/documentation/guide.rs index 165a618..f4f3806 100644 --- a/src/documentation/guide.rs +++ b/src/documentation/guide.rs @@ -2,7 +2,7 @@ //! stay in Session; the portable document owns composition and immutable evidence. use super::{Result, Session}; use crate::controls::Control; -use executable_docs::{Artifact, Binding, BindingValue, Doc, Resource}; +use lgtmd::{Artifact, Binding, BindingValue, Doc, Resource}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -138,7 +138,7 @@ impl Guide { pub(super) fn finish(mut self, session: &mut Session<'_>) -> Result<()> { let registered: BTreeSet<_> = self.media.keys().cloned().collect(); let captured: BTreeSet<_> = session.images.keys().cloned().collect(); - executable_docs::lifecycle::check_inventory(&captured, ®istered)?; + lgtmd::lifecycle::check_inventory(&captured, ®istered)?; if session.authored.is_some() { return Err("A scenario must finish exactly one authored guide".into()); } @@ -154,11 +154,8 @@ impl Guide { // supports public captured media plus private prose notes; a private // capture must fail instead of leaking through that legacy inventory. // Full portable exports support private resources without this limit. - for audience in [ - executable_docs::Audience::Reader, - executable_docs::Audience::Contributor, - ] { - executable_docs::lifecycle::compare( + for audience in [lgtmd::Audience::Reader, lgtmd::Audience::Contributor] { + lgtmd::lifecycle::compare( &session.images, &document.resource_files(audience)?, ).map_err(|error| format!("N3 authored guides require every capture in the reader narrative and no extra resource files: {error}"))?; @@ -172,7 +169,7 @@ impl Guide { mod tests { use super::*; use crate::documentation::{Capture, GENERATED, HEIGHT, WIDTH, hand_tool, root}; - use executable_docs::Audience; + use lgtmd::Audience; #[test] fn authored_hand_tool_reuses_frozen_evidence_and_keeps_notes_private() { diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index 829e020..b890bad 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -140,7 +140,7 @@ pub type Result = std::result::Result; #[cfg(test)] #[path = "tests/doc_navigation_tests.rs"] mod doc_navigation_tests; -type Artifacts = executable_docs::lifecycle::Files; +type Artifacts = lgtmd::lifecycle::Files; // Keep the actual editor canvas spacious at Full HD resolution. The shared // documentation frame adds its window chrome around this unscaled capture. const WIDTH: u32 = 1920; @@ -220,7 +220,7 @@ pub struct Session<'a> { annotations: Vec, recorder: Option, animations: BTreeSet, - authored: Option, + authored: Option, #[cfg(test)] extra_layout_pass: bool, } diff --git a/src/documentation/scenarios/hand_tool.rs b/src/documentation/scenarios/hand_tool.rs index 66b354f..108f1c1 100644 --- a/src/documentation/scenarios/hand_tool.rs +++ b/src/documentation/scenarios/hand_tool.rs @@ -1,8 +1,8 @@ use super::{Result, Session, guide::Guide, pointer}; use crate::{controls::Control, doc_harness::ClipSpec, editor::Tool}; -use executable_docs::Resource; -use executable_docs::prose; use glam::{Vec2, Vec3}; +use lgtmd::Resource; +use lgtmd::prose; use std::time::Duration; fn geometry(s: &Session<'_>) -> (Vec2, f32, Vec3) { diff --git a/src/documentation/scenarios/navigation.rs b/src/documentation/scenarios/navigation.rs index d343eb2..8a215db 100644 --- a/src/documentation/scenarios/navigation.rs +++ b/src/documentation/scenarios/navigation.rs @@ -1,7 +1,7 @@ use super::{Result, Session, guide::Guide}; use crate::{controls::Control, pointer_policy::DRAG_THRESHOLD, scroll_input::ScrollPhase}; -use executable_docs::prose; use glam::{Mat4, Vec2, Vec3}; +use lgtmd::prose; use std::time::Duration; fn matrix(s: &Session<'_>) -> Mat4 { diff --git a/tools/ci_runner.py b/tools/ci_runner.py index 55ca2aa..441274a 100644 --- a/tools/ci_runner.py +++ b/tools/ci_runner.py @@ -54,12 +54,9 @@ cargo test --locked --features viewport-measure --lib measurement:: cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize """ -DOC_FRAMEWORK_CHECKS = """\ -python3 crates/doc-harness/dev/verify.py -""" -CI_SCRIPT = CI_CHECKS_PREFIX + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" +CI_SCRIPT = CI_CHECKS_PREFIX + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" CI_PARTITION_SCRIPTS = { - "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + BENCHMARK_RUST_CHECKS + "exec cargo test --locked -- --skip documentation::tests::generated_documentation_is_current --exact\n", "guide": """\ python3 tools/ci_test_inventory.py diff --git a/tools/tests/test_ci_runner.py b/tools/tests/test_ci_runner.py index 8c54fde..0865ea4 100644 --- a/tools/tests/test_ci_runner.py +++ b/tools/tests/test_ci_runner.py @@ -107,7 +107,6 @@ def test_ci_runs_full_checks_without_recursing_through_just_or_runner(self): "node --test tools/tests/web_wrapper.test.mjs", "python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py'", "node --test tools/benchmark/tests/*.test.mjs", - "python3 crates/doc-harness/dev/verify.py", "cargo test --locked --features viewport-measure --lib measurement::", "cargo test --locked --features viewport-measure --lib scene_only_presentation_matches_egui_pixels_and_restores_texture_after_resize", "exec cargo test --locked", @@ -134,7 +133,6 @@ def test_ci_partition_modes_keep_checks_and_execute_the_validated_exact_partitio self.assertIn("exec " + " ".join(selected) + "\n", script) self.assertLess(script.index("python3 tools/ci_test_inventory.py"), script.index("exec cargo test")) self.assertEqual(runner.CI_CHECKS_PREFIX in script, mode == "checks") - self.assertEqual(runner.DOC_FRAMEWORK_CHECKS in script, mode == "checks") self.assertIn("LP_NATIVE_VECTOR_WIDTH=256", arguments) self.assertIn("MESA_SHADER_CACHE_DISABLE=true", arguments) @@ -160,37 +158,6 @@ def test_partition_inventory_failure_prevents_test_execution_in_both_shell_scrip calls = record.read_text().splitlines() if record.exists() else [] self.assertEqual(calls, ["fmt --all --check", "clippy --locked --workspace --all-targets -- -D warnings"] if mode == "checks" else []) - def test_documentation_framework_failure_stops_the_remaining_rust_gates(self): - commands = self.root / "commands" - commands.mkdir() - cargo = commands / "cargo" - cargo.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$N3_TEST_RECORD\"\n") - cargo.chmod(0o755) - python = commands / "python3" - python.write_text( - "#!/bin/sh\n" - "if [ \"$1\" = crates/doc-harness/dev/verify.py ]; then\n" - " printf '%s\\n' 'portable framework gate' >> \"$N3_TEST_RECORD\"\n" - " exit 19\n" - "fi\n" - ) - python.chmod(0o755) - node = commands / "node" - node.write_text("#!/bin/sh\nexit 0\n") - node.chmod(0o755) - record = self.root / "cargo-calls.txt" - environment = {**os.environ, "PATH": f"{commands}:{os.environ['PATH']}", "N3_TEST_RECORD": str(record)} - for label, script in (("ci", runner.CI_SCRIPT), ("checks", runner.CI_PARTITION_SCRIPTS["checks"])): - with self.subTest(mode=label): - record.unlink(missing_ok=True) - result = subprocess.run(["/bin/sh", "-eu", "-c", script], cwd=self.root, env=environment, check=False) - self.assertEqual(result.returncode, 19) - self.assertEqual(record.read_text().splitlines(), [ - "fmt --all --check", - "clippy --locked --workspace --all-targets -- -D warnings", - "portable framework gate", - ]) - def test_environment_receipt_reports_only_architecture_libc_and_cpu_features(self): cpuinfo = """processor : 0 model name : private host name