From 30869779c4a34baaac16e55dd08ebc15dbf3eca7 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 4 Oct 2026 19:34:43 +0900 Subject: [PATCH] feat: extract configurable executable documentation framework --- .../skills/docs-driven-development/SKILL.md | 54 +- .github/workflows/ci.yml | 8 +- .oxfmtrc.json | 2 + AGENTS.md | 66 +- CONTRIBUTING.md | 2 +- Cargo.lock | 60 ++ Cargo.toml | 6 + TODO.md | 1 + crates/doc-example-config/.gitignore | 2 + crates/doc-example-config/Cargo.toml | 22 + crates/doc-example-config/README.md | 130 +++ .../config/contributor/.ownership.json | 14 + .../configure-a-port/execution-checks.json | 9 + .../configure-a-port/invalid-input.json | 3 + .../configure-a-port/recovered-command.txt | 3 + .../configure-a-port/rejected-command.txt | 3 + .../configure-a-port/saved-configuration.json | 3 + .../configure-a-port/successful-command.txt | 3 + .../assets/configure-a-port/valid-input.json | 3 + .../config/contributor/configure-a-port.md | 129 +++ .../baseline/config/contributor/manifest.json | 380 ++++++++ .../baseline/config/reader/.ownership.json | 13 + .../configure-a-port/invalid-input.json | 3 + .../configure-a-port/recovered-command.txt | 3 + .../configure-a-port/rejected-command.txt | 3 + .../configure-a-port/saved-configuration.json | 3 + .../configure-a-port/successful-command.txt | 3 + .../assets/configure-a-port/valid-input.json | 3 + .../config/reader/configure-a-port.md | 113 +++ .../baseline/config/reader/manifest.json | 310 ++++++ .../serde-json/contributor/.ownership.json | 12 + .../parse-a-typed-list/error-observation.json | 6 + .../parse-a-typed-list/formatted-list.json | 5 + .../parse-a-typed-list/mismatched-input.json | 1 + .../assets/parse-a-typed-list/type-error.txt | 1 + .../parse-a-typed-list/valid-input.json | 1 + .../serde-json/contributor/manifest.json | 314 ++++++ .../contributor/parse-a-typed-list.md | 102 ++ .../serde-json/reader/.ownership.json | 11 + .../parse-a-typed-list/formatted-list.json | 5 + .../parse-a-typed-list/mismatched-input.json | 1 + .../assets/parse-a-typed-list/type-error.txt | 1 + .../parse-a-typed-list/valid-input.json | 1 + .../baseline/serde-json/reader/manifest.json | 274 ++++++ .../serde-json/reader/parse-a-typed-list.md | 94 ++ crates/doc-example-config/examples/docs.rs | 19 + .../doc-example-config/examples/serde_json.rs | 179 ++++ .../examples/support/mod.rs | 222 +++++ crates/doc-example-config/src/lib.rs | 210 ++++ crates/doc-example-config/src/main.rs | 40 + crates/doc-example-config/tests/cli.rs | 50 + crates/doc-example-config/tests/guide.rs | 62 ++ crates/doc-harness/Cargo.toml | 21 + crates/doc-harness/LICENSE | 21 + crates/doc-harness/README.md | 315 ++++++ crates/doc-harness/examples/configured.rs | 144 +++ crates/doc-harness/examples/preview.rs | 155 +++ crates/doc-harness/src/layout.rs | 110 +++ crates/doc-harness/src/lib.rs | 37 + crates/doc-harness/src/lifecycle.rs | 686 ++++++++++++++ crates/doc-harness/src/model.rs | 796 ++++++++++++++++ crates/doc-harness/src/prose.rs | 55 ++ crates/doc-harness/src/render.rs | 773 +++++++++++++++ crates/doc-harness/src/runner.rs | 362 +++++++ crates/doc-harness/src/store.rs | 372 ++++++++ crates/doc-harness/tests/acceptance.rs | 186 ++++ crates/doc-harness/tests/json_review.rs | 65 ++ crates/doc-harness/tests/layout.rs | 375 ++++++++ crates/doc-harness/tests/lifecycle.rs | 296 ++++++ crates/doc-harness/tests/model.rs | 597 ++++++++++++ crates/doc-harness/tests/prose.rs | 107 +++ crates/doc-harness/tests/render_review.rs | 134 +++ crates/doc-harness/tests/runner_aliases.rs | 211 +++++ crates/doc-harness/tests/runner_config.rs | 343 +++++++ crates/doc-harness/tests/section_review.rs | 86 ++ crates/doc-harness/tests/store.rs | 836 ++++++++++++++++ crates/doc-harness/tests/store_layout.rs | 171 ++++ docs/architecture/documentation-pipeline.md | 23 +- docs/architecture/executable-documents.md | 189 ++++ docs/development.md | 58 +- docs/templates/hand-tool.md.in | 36 - docs/templates/navigation.md.in | 88 -- justfile | 22 +- ...0003-executable-documentation-framework.md | 894 ++++++++++++++++++ rfcs/README.md | 9 +- src/documentation/artifacts.rs | 278 +++--- src/documentation/features.rs | 62 +- src/documentation/guide.rs | 242 +++++ src/documentation/mod.rs | 7 +- src/documentation/renderer_baseline.rs | 4 + src/documentation/scenarios/hand_tool.rs | 93 +- src/documentation/scenarios/navigation.rs | 220 ++++- src/documentation/tests/harness.rs | 3 + src/lib.rs | 16 +- tools/ci_runner.py | 13 +- tools/tests/test_ci_runner.py | 39 +- 96 files changed, 12150 insertions(+), 368 deletions(-) create mode 100644 crates/doc-example-config/.gitignore create mode 100644 crates/doc-example-config/Cargo.toml create mode 100644 crates/doc-example-config/README.md create mode 100644 crates/doc-example-config/baseline/config/contributor/.ownership.json create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt create mode 100644 crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json create mode 100644 crates/doc-example-config/baseline/config/contributor/configure-a-port.md create mode 100644 crates/doc-example-config/baseline/config/contributor/manifest.json create mode 100644 crates/doc-example-config/baseline/config/reader/.ownership.json create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt create mode 100644 crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json create mode 100644 crates/doc-example-config/baseline/config/reader/configure-a-port.md create mode 100644 crates/doc-example-config/baseline/config/reader/manifest.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/.ownership.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/manifest.json create mode 100644 crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md create mode 100644 crates/doc-example-config/baseline/serde-json/reader/.ownership.json create mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json create mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json create mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt create mode 100644 crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json create mode 100644 crates/doc-example-config/baseline/serde-json/reader/manifest.json create mode 100644 crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md create mode 100644 crates/doc-example-config/examples/docs.rs create mode 100644 crates/doc-example-config/examples/serde_json.rs create mode 100644 crates/doc-example-config/examples/support/mod.rs create mode 100644 crates/doc-example-config/src/lib.rs create mode 100644 crates/doc-example-config/src/main.rs create mode 100644 crates/doc-example-config/tests/cli.rs create mode 100644 crates/doc-example-config/tests/guide.rs create mode 100644 crates/doc-harness/Cargo.toml create mode 100644 crates/doc-harness/LICENSE create mode 100644 crates/doc-harness/README.md create mode 100644 crates/doc-harness/examples/configured.rs create mode 100644 crates/doc-harness/examples/preview.rs create mode 100644 crates/doc-harness/src/layout.rs create mode 100644 crates/doc-harness/src/lib.rs create mode 100644 crates/doc-harness/src/lifecycle.rs create mode 100644 crates/doc-harness/src/model.rs create mode 100644 crates/doc-harness/src/prose.rs create mode 100644 crates/doc-harness/src/render.rs create mode 100644 crates/doc-harness/src/runner.rs create mode 100644 crates/doc-harness/src/store.rs create mode 100644 crates/doc-harness/tests/acceptance.rs create mode 100644 crates/doc-harness/tests/json_review.rs create mode 100644 crates/doc-harness/tests/layout.rs create mode 100644 crates/doc-harness/tests/lifecycle.rs create mode 100644 crates/doc-harness/tests/model.rs create mode 100644 crates/doc-harness/tests/prose.rs create mode 100644 crates/doc-harness/tests/render_review.rs create mode 100644 crates/doc-harness/tests/runner_aliases.rs create mode 100644 crates/doc-harness/tests/runner_config.rs create mode 100644 crates/doc-harness/tests/section_review.rs create mode 100644 crates/doc-harness/tests/store.rs create mode 100644 crates/doc-harness/tests/store_layout.rs create mode 100644 docs/architecture/executable-documents.md delete mode 100644 docs/templates/hand-tool.md.in delete mode 100644 docs/templates/navigation.md.in create mode 100644 rfcs/0003-executable-documentation-framework.md create mode 100644 src/documentation/guide.rs diff --git a/.agents/skills/docs-driven-development/SKILL.md b/.agents/skills/docs-driven-development/SKILL.md index 9b26465..f6af715 100644 --- a/.agents/skills/docs-driven-development/SKILL.md +++ b/.agents/skills/docs-driven-development/SKILL.md @@ -17,17 +17,19 @@ user, maintaining an existing documented feature, or improving the harness. An internal refactor need not invent a new tutorial. Existing behavioral and unit tests still matter, particularly for cases a user guide should not enumerate. -Follow [AGENTS.md](../../../AGENTS.md). Read the relevant feature's template and -scenario before changing its behavior. Consult the +Follow [AGENTS.md](../../../AGENTS.md). Read the relevant feature's scenario and +its registered narrative source before changing its behavior. Consult the [pipeline contract](../../../docs/architecture/documentation-pipeline.md) for -current APIs, capture defaults, encoding limits, and examples. Do not duplicate -that technical manual in this skill. +capture defaults, encoding limits, and examples, and the +[executable document boundary](../../../docs/architecture/executable-documents.md) +for the maintained SDK and N3 adapter. This skill governs N3's workflow; the +portable SDK does not require it. Do not duplicate the API manuals here. ## The development and feedback loop 1. **Describe the experience.** Start from what the user wants to do, what they act on, what feedback they see, and how they finish or recover. Draft or adjust - the owning user-guide template before or alongside implementation. Write in + the owning user-guide narrative before or alongside implementation. Write in the user's vocabulary; implementation notes belong in architecture docs. 2. **Make the important promises executable.** Extend the matching scenario with meaningful outcomes. For a regression, reproduce the failure before fixing it @@ -62,33 +64,47 @@ its user explanation and executable evidence land with its implementation. ## Sources of truth -Each current guide feature has one template, one scenario, and one registry entry: +Each guide feature has one scenario, one registry entry, and exactly one narrative +source. `hand-tool` and `navigation` author their narrative in Rust through the +N3 `Guide` adapter and register `template: None`. Other features retain their +registered templates. Do not keep both prose forms for one feature. -| Source | Owns | -| -------------------------------------------------------------------- | -------------------------------------------------- | -| [docs/templates](../../../docs/templates/) | User narrative and semantic bindings | -| [src/documentation/scenarios](../../../src/documentation/scenarios/) | Real replay and behavioral assertions | -| [features.rs](../../../src/documentation/features.rs) | Feature registration | -| [input/bindings.rs](../../../src/input/bindings.rs) | Production key bindings, labels, and replay inputs | -| [ui/controls.rs](../../../src/ui/controls.rs) | Control identities and witnessed UI names/paths | -| [docs/guide](../../../docs/guide/) | Generated user pages, media, and evidence manifest | +| Source | Owns | +| -------------------------------------------------------------------- | ----------------------------------------------------- | +| [docs/templates](../../../docs/templates/) | Retained legacy narratives and semantic bindings | +| [src/documentation/scenarios](../../../src/documentation/scenarios/) | Real replay, assertions, and Rust-authored narratives | +| [features.rs](../../../src/documentation/features.rs) | Feature registration and narrative-source selection | +| [input/bindings.rs](../../../src/input/bindings.rs) | Production key bindings, labels, and replay inputs | +| [ui/controls.rs](../../../src/ui/controls.rs) | Control identities and witnessed UI names/paths | +| [docs/guide](../../../docs/guide/) | Generated user pages, media, and evidence manifest | Edit the sources, never the generated guide or its media by hand. The browser preview displays these same files; it does not own another content tree or approve a baseline. +For paired Reader and Contributor review, `just docs build --out .cache/docs-candidate` +captures one replay into a fresh candidate tree without accepting the baseline. `just fmt-docs` formats authored Markdown and `.md.in` templates through the version-pinned `tools/format_docs.py` wrapper on the native host. It needs Node.js/npm with `npx` there. The wrapper checks that all template bindings keep their order before writing any template. -Generated guides are excluded; after a template formatting change, regenerate -with `just docs update` and review the resulting text through the same pipeline. +Rust-authored narratives follow Rust formatting. Generated guides are excluded; +when formatting changes narrative output, regenerate with `just docs update` and +review the resulting text through the same pipeline. Keep the audience boundary explicit. Repository-owned, GitHub-hosted documents such as `TODO.md`, contributor instructions, and engineering/research notes may link to user docs. The user guide and its navigation must never link back to those documents. A shared directory does not imply a shared audience; keep -planning and contributor references out of templates and generated guide indexes. +planning and contributor references out of reader narratives and generated guide indexes. + +Rust-authored guides use typed control, shortcut, and media handles from `Guide`. +Labels come from witnessed controls and canonical bindings; captures still run +through `Session`. A label or resource handle does not replace a meaningful +behavioral assertion. Keep the prose beside the replay and use the adapter's +finish step so registered and captured evidence stay accountable. + +For retained templates: - Use `{{shortcut:selection.duplicate}}` for an application shortcut. The binding registry supplies the `` keys. Replay it with `Session::shortcut`, or @@ -109,7 +125,7 @@ A documented contract must not silently drift. Preserve failures for violated assertions, missing or unwitnessed controls, unknown bindings, incorrect media kinds, missing or unreferenced captures, changed output, and orphaned artifacts. Retiring a feature means explicitly reconciling its implementation, registry, -scenario, template, references, and generated artifacts. +scenario, narrative source, references, and generated artifacts. Treat a regression that escapes an intended assertion as a coverage problem to repair. A page's existence does not prove every sentence or every possible @@ -137,6 +153,8 @@ 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`. ## Evolve the harness when the user story needs it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 369ec85..4d567be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: .cache/ci/linux-amd64/target/debug/.fingerprint .cache/ci/linux-amd64/target/.rustc_info.json .cache/ci/linux-amd64/home/.npm - key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'vendor/**') }} + key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'crates/**/Cargo.toml', 'Cargo.lock', 'vendor/**') }} - name: Check formatting, lint, tooling, and focused regressions env: N3_CI_FAILURE_ARTIFACTS: "1" @@ -95,7 +95,7 @@ jobs: .cache/ci/linux-amd64/target/debug/.fingerprint .cache/ci/linux-amd64/target/.rustc_info.json .cache/ci/linux-amd64/home/.npm - key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'vendor/**') }} + key: n3-v1-ubuntu24-linux-amd64-${{ steps.environment.outputs.image }}-${{ hashFiles('Cargo.toml', 'crates/**/Cargo.toml', 'Cargo.lock', 'vendor/**') }} - name: Replay every guide feature and check exact artifacts env: N3_CI_FAILURE_ARTIFACTS: "1" @@ -143,7 +143,7 @@ jobs: target/debug/build target/debug/.fingerprint target/.rustc_info.json - key: n3-v1-macos15-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }} + key: n3-v1-macos15-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'crates/**/Cargo.toml', 'Cargo.lock', 'vendor/**') }} - name: Restore the pinned just installation id: just uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -221,7 +221,7 @@ jobs: target/wasm32-unknown-unknown/release/build target/wasm32-unknown-unknown/release/.fingerprint target/.rustc_info.json - key: n3-v2-wasm-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }} + key: n3-v2-wasm-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'crates/**/Cargo.toml', 'Cargo.lock', 'vendor/**') }} - name: Restore pinned browser build tools id: tools uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c9935d6..f3f81eb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,6 +6,8 @@ "/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 5f20b66..e872502 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,13 @@ boundaries. The current Editor still depends on egui and is not a public kernel ## Code organization -Keep one application crate until a real consumer and a clear dependency boundary -justify extraction. Prefer focused modules over speculative crates, public APIs, -or generic frameworks. +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 +[executable documents](docs/architecture/executable-documents.md) for this boundary. Read [platform boundaries and performance](docs/architecture/platform-boundaries.md) before changing host adapters or platform-dependent behavior. Share product @@ -79,19 +83,21 @@ 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 | +| 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 | Keep code, identifiers, comments, tests, logs, and authored UI strings in English. Explain non-obvious behavior and platform limitations near the owning code. @@ -118,10 +124,14 @@ Native and web share one feature UI and user guide. Follow the [shared product contract](docs/architecture/platform-boundaries.md#one-product-experience) for capability-based availability and the boundary with contributor documentation. -Every guide feature owns one Rust scenario in `src/documentation/scenarios/` and -one template in `docs/templates/`, registered in -`src/documentation/features.rs`. Edit those sources; never hand-edit generated -pages, media, or the manifest in `docs/guide/`. +Every guide feature owns one Rust scenario in `src/documentation/scenarios/` +and exactly one narrative source, selected in `src/documentation/features.rs`. +Rust-authored guides compose their narrative in the scenario through the N3 +`Guide` adapter and register `template: None`; retained legacy features use one +`docs/templates/*.md.in` template. `hand-tool` and `navigation` are Rust-authored. +Do not keep a parallel template or generated-prose copy for an authored guide. +Edit the registered source; never hand-edit pages, media, or the manifest in +`docs/guide/`. - Assert actual behavior, including meaningful intermediate states for a gesture. Fixture setup may prepare state; an illustrated interaction must use production @@ -129,8 +139,11 @@ pages, media, or the manifest in `docs/guide/`. - Bind labels and paths to witnessed controls. Bind callouts to live control IDs, not stale screen coordinates. Virtual input and annotations only paint; they must not alter focus, hover, selection, or document state. -- Use `{{shortcut:semantic-id}}` for application shortcuts, `{{control:id}}` for - witnessed menu paths, and `{{key:Name}}` / `{{modifier:name}}` for literal +- Rust-authored guides use the `Guide` adapter's typed control, shortcut, and + captured-resource handles. A presented label is not evidence that an action + ran; retain the actual replay and behavioral assertions. +- In retained templates, use `{{shortcut:semantic-id}}` for application shortcuts, + `{{control:id}}` for witnessed menu paths, and `{{key:Name}}` / `{{modifier:name}}` for literal text-entry keys or physical gesture modifiers. Do not copy remappable keys into prose or scenario values. Replay actions with `Session::shortcut` and its held-key helpers; reserve raw keys for literal input or intentional ownership @@ -146,6 +159,9 @@ pages, media, or the manifest in `docs/guide/`. The [pipeline contract](docs/architecture/documentation-pipeline.md) documents replay, timing, bindings, overlays, encoding limits, and authoring examples. +`just docs build --out .cache/docs-candidate` runs one replay and writes fresh +Reader and Contributor review trees. It does not accept a canonical baseline; +`just docs check/update` retain their existing reader-guide roles. ## Iteration and verification @@ -153,7 +169,8 @@ For an implementation change: 1. Identify the owning model/input/UI boundary and any affected guide scenario. 2. Make a focused change and add meaningful behavioral regressions where needed. - For visible interactions, update the owning scenario and template together. + For visible interactions, update the owning scenario and its registered + narrative together. 3. If guide output intentionally changes, run `just docs update`, then inspect the generated text, stills, and animation playback before accepting the baseline. Use the native host renderer; local development must not require CI infrastructure. @@ -167,7 +184,8 @@ Contributor setup is `just setup` (Node.js 24/npm with `npx`, a warmed cache for the Oxfmt version pinned in `tools/format_docs.py`, and a repository-local pre-push hook). `just fmt` formats Rust and authored docs; `TODO.md` is excluded. Generated guide files are excluded from formatting: format -their templates, then regenerate through the ordinary pipeline when needed. +the authored Rust scenario or retained template, then regenerate through the +ordinary pipeline when its output intentionally changes. The pre-push hook verifies a clean current `HEAD`, not arbitrary refs or dirty working content. `just test`, `just docs update/check`, and `just verify` run natively, as does the pre-push hook. Developer experience comes first: never make diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78c33ea..ac5549e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ repository's pre-push hook. The optional CI container supplies Node. Run Keep changes focused and describe the behavior they add or fix. For implementation changes, run `just verify` and report any device interactions that still need manual validation. User-visible changes should update their owning guide -scenario and template, with generated media reviewed alongside the code. +scenario and registered narrative, with generated media reviewed alongside the code. The pre-push hook requires a clean checkout and pushed commits matching `HEAD`, then runs `just verify`. It never stages, formats, commits, or pushes for you. diff --git a/Cargo.lock b/Cargo.lock index 27c2ae7..cc91d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,16 @@ 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" @@ -728,6 +738,18 @@ 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" @@ -1498,6 +1520,7 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "executable-docs", "glam", "gltf", "image", @@ -2160,6 +2183,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.30" @@ -2689,6 +2730,19 @@ 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" @@ -2873,6 +2927,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index d6ac518..682681d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,11 @@ 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 = [] @@ -48,6 +53,7 @@ wgpu = "30.0.1" winit = "=0.30.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +executable-docs = { path = "crates/doc-harness" } 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 42df30a..ac259f5 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +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). | | 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 new file mode 100644 index 0000000..d72160d --- /dev/null +++ b/crates/doc-example-config/.gitignore @@ -0,0 +1,2 @@ +/.cache/ +/target/ diff --git a/crates/doc-example-config/Cargo.toml b/crates/doc-example-config/Cargo.toml new file mode 100644 index 0000000..542630c --- /dev/null +++ b/crates/doc-example-config/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "doc-example-config" +version = "0.0.0" +edition = "2024" +rust-version = "1.95" +publish = false +description = "Executable documentation consumers for a configuration CLI and serde_json" + +[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/README.md b/crates/doc-example-config/README.md new file mode 100644 index 0000000..131a33b --- /dev/null +++ b/crates/doc-example-config/README.md @@ -0,0 +1,130 @@ +# 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. 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. + +## 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. diff --git a/crates/doc-example-config/baseline/config/contributor/.ownership.json b/crates/doc-example-config/baseline/config/contributor/.ownership.json new file mode 100644 index 0000000..620396e --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/.ownership.json @@ -0,0 +1,14 @@ +{ + "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 new file mode 100644 index 0000000..d303a7f --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/execution-checks.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 100644 index 0000000..2e086da --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/invalid-input.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..a688bc2 --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/recovered-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..41058b6 --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/rejected-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..a13d52b --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/saved-configuration.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..d3e999d --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/successful-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..a13d52b --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/assets/configure-a-port/valid-input.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..b746d63 --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/configure-a-port.md @@ -0,0 +1,129 @@ +# 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 new file mode 100644 index 0000000..a525e98 --- /dev/null +++ b/crates/doc-example-config/baseline/config/contributor/manifest.json @@ -0,0 +1,380 @@ +{ + "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 new file mode 100644 index 0000000..61c1c53 --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/.ownership.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..2e086da --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/invalid-input.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..a688bc2 --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/recovered-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..41058b6 --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/rejected-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..a13d52b --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/saved-configuration.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..d3e999d --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/successful-command.txt @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..a13d52b --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/assets/configure-a-port/valid-input.json @@ -0,0 +1,3 @@ +{ + "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 new file mode 100644 index 0000000..98276d6 --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/configure-a-port.md @@ -0,0 +1,113 @@ +# 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 new file mode 100644 index 0000000..33d30df --- /dev/null +++ b/crates/doc-example-config/baseline/config/reader/manifest.json @@ -0,0 +1,310 @@ +{ + "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 new file mode 100644 index 0000000..a2c5e90 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/.ownership.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..67baec7 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/error-observation.json @@ -0,0 +1,6 @@ +{ + "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 new file mode 100644 index 0000000..aa50563 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/formatted-list.json @@ -0,0 +1,5 @@ +[ + 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 new file mode 100644 index 0000000..a47bcf6 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/mismatched-input.json @@ -0,0 +1 @@ +[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 new file mode 100644 index 0000000..8da8dd3 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/type-error.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..3ec03bc --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/assets/parse-a-typed-list/valid-input.json @@ -0,0 +1 @@ +[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 new file mode 100644 index 0000000..8cb137a --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/manifest.json @@ -0,0 +1,314 @@ +{ + "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 new file mode 100644 index 0000000..3cbc9bf --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/contributor/parse-a-typed-list.md @@ -0,0 +1,102 @@ +# 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 new file mode 100644 index 0000000..a493293 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/.ownership.json @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 0000000..aa50563 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/formatted-list.json @@ -0,0 +1,5 @@ +[ + 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 new file mode 100644 index 0000000..a47bcf6 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/mismatched-input.json @@ -0,0 +1 @@ +[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 new file mode 100644 index 0000000..8da8dd3 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/type-error.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..3ec03bc --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/assets/parse-a-typed-list/valid-input.json @@ -0,0 +1 @@ +[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 new file mode 100644 index 0000000..cc722f6 --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/manifest.json @@ -0,0 +1,274 @@ +{ + "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 new file mode 100644 index 0000000..dae6fbc --- /dev/null +++ b/crates/doc-example-config/baseline/serde-json/reader/parse-a-typed-list.md @@ -0,0 +1,94 @@ +# 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 new file mode 100644 index 0000000..73cab64 --- /dev/null +++ b/crates/doc-example-config/examples/docs.rs @@ -0,0 +1,19 @@ +//! 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 new file mode 100644 index 0000000..634fce5 --- /dev/null +++ b/crates/doc-example-config/examples/serde_json.rs @@ -0,0 +1,179 @@ +//! 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 new file mode 100644 index 0000000..577830a --- /dev/null +++ b/crates/doc-example-config/examples/support/mod.rs @@ -0,0 +1,222 @@ +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 new file mode 100644 index 0000000..edacb25 --- /dev/null +++ b/crates/doc-example-config/src/lib.rs @@ -0,0 +1,210 @@ +//! 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 new file mode 100644 index 0000000..75d21bf --- /dev/null +++ b/crates/doc-example-config/src/main.rs @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..5915e74 --- /dev/null +++ b/crates/doc-example-config/tests/cli.rs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..c77a9dc --- /dev/null +++ b/crates/doc-example-config/tests/guide.rs @@ -0,0 +1,62 @@ +#[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/Cargo.toml b/crates/doc-harness/Cargo.toml new file mode 100644 index 0000000..75874e2 --- /dev/null +++ b/crates/doc-harness/Cargo.toml @@ -0,0 +1,21 @@ +[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 new file mode 100644 index 0000000..943658a --- /dev/null +++ b/crates/doc-harness/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..25e41fb --- /dev/null +++ b/crates/doc-harness/README.md @@ -0,0 +1,315 @@ +# 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. + +## 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/examples/configured.rs b/crates/doc-harness/examples/configured.rs new file mode 100644 index 0000000..ae13898 --- /dev/null +++ b/crates/doc-harness/examples/configured.rs @@ -0,0 +1,144 @@ +//! 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 new file mode 100644 index 0000000..1644f2c --- /dev/null +++ b/crates/doc-harness/examples/preview.rs @@ -0,0 +1,155 @@ +//! Disposable static rendering for human review, separate from guide generation. +use pulldown_cmark::{Event, Options, Parser, Tag, html}; +use std::{env, fs, path::Path}; + +fn main() -> 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<(), Box> { + let source = source.canonicalize()?; + let destination = executable_docs::lifecycle::absolute(destination)?; + executable_docs::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()); + } + fs::create_dir_all(&destination)?; + render_tree(&source, &destination)?; + Ok(()) +} + +fn preview_link(destination: &str) -> String { + if destination.contains(':') || destination.starts_with("//") { + return destination.to_owned(); + } + let (path, fragment) = destination.split_once('#').unwrap_or((destination, "")); + match path.strip_suffix(".md") { + Some(path) if fragment.is_empty() => format!("{path}.html"), + Some(path) => format!("{path}.html#{fragment}"), + None => destination.to_owned(), + } +} + +fn render_tree(source: &Path, destination: &Path) -> Result<(), Box> { + for entry in fs::read_dir(source)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_symlink() { + return Err(format!("Preview refuses symlink: {}", entry.path().display()).into()); + } + let output = destination.join(entry.file_name()); + if kind.is_dir() { + fs::create_dir(&output)?; + render_tree(&entry.path(), &output)?; + } else if entry.path().extension().is_some_and(|ext| ext == "md") { + let source = fs::read_to_string(entry.path())?; + let parser = + Parser::new_ext(&source, Options::ENABLE_TABLES).map(|event| match event { + Event::Start(Tag::Link { + link_type, + dest_url, + title, + id, + }) => Event::Start(Tag::Link { + link_type, + dest_url: preview_link(&dest_url).into(), + title, + id, + }), + other => other, + }); + let mut body = String::new(); + html::push_html(&mut body, parser); + fs::write( + output.with_extension("html"), + format!( + "Executable documentation review
{body}
" + ), + )?; + } else if entry.file_name() != ".ownership.json" { + fs::copy(entry.path(), output)?; + } + } + Ok(()) +} + +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::*; + + #[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_rewrites_local_pages_and_preserves_external_destinations() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let source = root.join("export"); + let destination = root.join("preview"); + fs::create_dir_all(source.join("nested")).unwrap(); + let markdown = "[Local](nested/other.md#section)\n\ + [Remote](https://example.com/other.md#section)\n\ + [Email](mailto:editor@example.md)\n\ + [Network](//example.com/other.md)\n"; + fs::write(source.join("guide.md"), markdown).unwrap(); + fs::write(source.join("nested/other.md"), "# Other").unwrap(); + fs::write(source.join(".fixture.txt"), "A documented hidden file").unwrap(); + fs::write(source.join(".ownership.json"), "store metadata").unwrap(); + preview(&source, &destination).unwrap(); + let html = fs::read_to_string(destination.join("guide.html")).unwrap(); + for target in [ + "nested/other.html#section", + "https://example.com/other.md#section", + "mailto:editor@example.md", + "//example.com/other.md", + ] { + assert!(html.contains(&format!("href=\"{target}\"")), "{html}"); + } + assert!(destination.join("nested/other.html").is_file()); + assert_eq!( + fs::read_to_string(destination.join(".fixture.txt")).unwrap(), + "A documented hidden file" + ); + assert!(!destination.join(".ownership.json").exists()); + assert_eq!( + fs::read_to_string(source.join("guide.md")).unwrap(), + markdown + ); + } +} diff --git a/crates/doc-harness/src/layout.rs b/crates/doc-harness/src/layout.rs new file mode 100644 index 0000000..0ad01d9 --- /dev/null +++ b/crates/doc-harness/src/layout.rs @@ -0,0 +1,110 @@ +use crate::model::{Document, valid_id}; +use crate::{Result, render::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 new file mode 100644 index 0000000..1130046 --- /dev/null +++ b/crates/doc-harness/src/lib.rs @@ -0,0 +1,37 @@ +#![doc = include_str!("../README.md")] + +mod layout; +pub mod lifecycle; +mod model; +mod prose; +mod render; +pub mod runner; +pub mod store; + +pub use layout::ExportLayout; +pub use model::{ + Artifact, Audience, Binding, BindingValue, Block, Checked, Claim, Doc, 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 new file mode 100644 index 0000000..e43ce3d --- /dev/null +++ b/crates/doc-harness/src/lifecycle.rs @@ -0,0 +1,686 @@ +//! 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/model.rs b/crates/doc-harness/src/model.rs new file mode 100644 index 0000000..b489a86 --- /dev/null +++ b/crates/doc-harness/src/model.rs @@ -0,0 +1,796 @@ +use crate::Result; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::BTreeMap, + marker::PhantomData, + path::Path, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); + +#[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 { + reference: Reference, + observed: Value, + 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); + +/// 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) + } +} + +#[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 struct Doc { + owner: u64, + ids: BTreeMap, + document: Document, + errors: Vec, +} + +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'_') +} + +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::render::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::render::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::render::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::render::validate_document(&self.document)?; + Ok(self.document) + } +} diff --git a/crates/doc-harness/src/prose.rs b/crates/doc-harness/src/prose.rs new file mode 100644 index 0000000..08eb77e --- /dev/null +++ b/crates/doc-harness/src/prose.rs @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..b0a5b4b --- /dev/null +++ b/crates/doc-harness/src/render.rs @@ -0,0 +1,773 @@ +use crate::model::{BindingValue, Content, Document, Inline, Kind, Node, Prose, Reference}; +use crate::{Audience, ExportLayout, Result}; +use pulldown_cmark::{BrokenLink, Event, Options, Parser, Tag}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +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)] +struct Rendered { + text: String, + html: Vec>, +} + +impl Rendered { + fn text(text: impl Into) -> Self { + Self { + text: text.into(), + html: Vec::new(), + } + } + + fn html(text: String) -> Self { + let len = text.len(); + Self { + text, + html: std::iter::once(0..len).collect(), + } + } + + 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), + ); + } + + 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. +fn parsed_links(rendered: &Rendered, boundaries: &[usize]) -> 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" + )); + } + 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( + "Raw HTML is unsupported in authored Markdown; use typed document blocks" + .into(), + ); + } + html_ranges.push(range); + } + _ => {} + } + } + if !unresolved.is_empty() { + return Err(format!( + "Unresolved Markdown references: {}", + unresolved.join(", ") + )); + } + if rendered.html.iter().any(|expected| { + expected.clone().any(|position| { + !text.as_bytes()[position].is_ascii_whitespace() + && !html_ranges.iter().any(|actual| actual.contains(&position)) + }) + }) { + return Err("Markdown swallowed a generated binding or block anchor; typed HTML cannot be placed inside code or HTML syntax".into()); + } + Ok(links) +} + +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 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 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 +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +pub(crate) fn artifact_path(document: &Document, id: &str) -> String { + ExportLayout::default().resource_path(document, id) +} + +fn node_links(document: &Document, node: &Node, layout: &ExportLayout) -> Result> { + match &node.content { + Content::Markdown(text) => markdown_links(text), + Content::MarkdownParts(parts) => { + parsed_links(&render_prose(document, parts, true, layout), &[]) + } + _ => Ok(Vec::new()), + } +} + +fn visible_nodes(document: &Document, audience: Audience) -> impl Iterator { + document + .nodes + .iter() + .filter(move |node| audience == Audience::Contributor || !node.is_note()) +} + +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(()) +} + +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(()) +} + +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()); + 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)?; + 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(), + ) +} + +/// 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. +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("/") +} + +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))) +} + +/// 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 profiles = BTreeSet::new(); + let mut manifest_documents = Vec::new(); + let mut manifest_artifacts = Vec::new(); + 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_artifacts.push(json!({"path":path,"kind":"page","mime":"text/markdown","bytes":page.len(),"sha256":sha256(&page),"document":document.id})); + 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_artifacts.push(json!({"path":path,"id":id,"document":document.id,"kind":"resource","mime":artifact.mime,"producer":artifact.producer,"profile":artifact.profile,"bytes":artifact.bytes.len(),"sha256":sha256(&artifact.bytes)})); + profiles.insert((artifact.producer.clone(), artifact.profile.clone())); + if files.insert(path.clone(), artifact.bytes.clone()).is_some() { + return Err(format!("Duplicate artifact path: {path}")); + } + } + let reader_refs: BTreeSet<_> = document + .nodes + .iter() + .filter(|node| !node.is_note()) + .flat_map(Node::references) + .map(|r| &r.id) + .collect(); + let checks: Vec<_> = document.checks.values().map(|check| { + if audience == Audience::Contributor || reader_refs.contains(&check.id) { + json!({"id":check.id,"kind":check.kind,"passed":true,"actual":check.actual,"expected":check.expected}) + } else { + json!({"id":check.id,"kind":check.kind,"passed":true}) + } + }).collect(); + let blocks: Vec<_> = 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: Vec<_> = node + .references() + .into_iter() + .map(|r| json!({"id":r.id,"kind":r.kind})) + .collect(); + json!({"id":node.id,"kind":kind,"references":references}) + }) + .collect(); + let visible_refs: BTreeSet<_> = visible_nodes(document, audience) + .flat_map(Node::references) + .map(|r| &r.id) + .collect(); + let bindings: BTreeMap<_, _> = document + .bindings + .iter() + .filter(|(id, _)| visible_refs.contains(id)) + .collect(); + let mut record = + json!({"id":document.id,"title":document.title,"checks":checks,"blocks":blocks}); + // Preserve existing document exports exactly when no bindings are used. + if !bindings.is_empty() { + record["bindings"] = serde_json::to_value(bindings).map_err(|e| e.to_string())?; + } + manifest_documents.push(record); + } + // 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 + )); + } + } + } + } + manifest_artifacts.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str())); + let profiles: Vec<_> = profiles + .into_iter() + .map(|(producer, profile)| json!({"producer":producer,"profile":profile})) + .collect(); + let mut manifest = json!({"schema_version":1,"audience":audience,"profiles":profiles,"documents":manifest_documents,"artifacts":manifest_artifacts}); + manifest.sort_all_objects(); + let mut bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; + bytes.push(b'\n'); + files.insert("manifest.json".into(), bytes); + Ok(files) +} diff --git a/crates/doc-harness/src/runner.rs b/crates/doc-harness/src/runner.rs new file mode 100644 index 0000000..e2fa3e5 --- /dev/null +++ b/crates/doc-harness/src/runner.rs @@ -0,0 +1,362 @@ +//! 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 new file mode 100644 index 0000000..20a9614 --- /dev/null +++ b/crates/doc-harness/src/store.rs @@ -0,0 +1,372 @@ +//! Structured SDK manifest policy over the shared artifact lifecycle. +use crate::{ + Result, + lifecycle::{self, Ownership}, +}; +pub use lifecycle::Files; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, +}; + +pub(crate) fn absolute(path: &Path) -> Result { + lifecycle::absolute(path) +} + +fn 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 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(baseline)?; + let mut retired = BTreeSet::new(); + for artifact in array(&old, "artifacts")? { + let path = field(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(baseline)?; + let new = manifest(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 = resource_profiles(&old)?; + let next = resource_profiles(&new)?; + 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 resource_profiles( + manifest: &serde_json::Value, +) -> Result> { + let mut profiles = BTreeMap::new(); + for artifact in manifest["artifacts"] + .as_array() + .ok_or("Manifest artifacts must be an array")? + { + if artifact["kind"] != "resource" { + continue; + } + let field = |name: &str| -> Result { + artifact[name] + .as_str() + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("Resource manifest requires {name}")) + }; + let identity = (field("document")?, field("id")?); + let profile = (field("producer")?, field("profile")?); + if profiles.insert(identity.clone(), profile).is_some() { + return Err(format!( + "Duplicate resource manifest identity: {identity:?}" + )); + } + } + Ok(profiles) +} + +fn validate(files: &Files) -> Result<()> { + validate_tree(files, None) +} + +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(()) +} + +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 = 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::render::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_some_and(|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(()) +} + +/// 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/tests/acceptance.rs b/crates/doc-harness/tests/acceptance.rs new file mode 100644 index 0000000..4a72629 --- /dev/null +++ b/crates/doc-harness/tests/acceptance.rs @@ -0,0 +1,186 @@ +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 new file mode 100644 index 0000000..68efe70 --- /dev/null +++ b/crates/doc-harness/tests/json_review.rs @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..2e81160 --- /dev/null +++ b/crates/doc-harness/tests/layout.rs @@ -0,0 +1,375 @@ +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 new file mode 100644 index 0000000..26add60 --- /dev/null +++ b/crates/doc-harness/tests/lifecycle.rs @@ -0,0 +1,296 @@ +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 new file mode 100644 index 0000000..ac0f8b4 --- /dev/null +++ b/crates/doc-harness/tests/model.rs @@ -0,0 +1,597 @@ +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 new file mode 100644 index 0000000..3e9494e --- /dev/null +++ b/crates/doc-harness/tests/prose.rs @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..decf3be --- /dev/null +++ b/crates/doc-harness/tests/render_review.rs @@ -0,0 +1,134 @@ +use executable_docs::{Artifact, Audience, BindingValue, Doc, Document}; +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 new file mode 100644 index 0000000..22037a6 --- /dev/null +++ b/crates/doc-harness/tests/runner_config.rs @@ -0,0 +1,343 @@ +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 new file mode 100644 index 0000000..a9305b0 --- /dev/null +++ b/crates/doc-harness/tests/section_review.rs @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..1ddf9b5 --- /dev/null +++ b/crates/doc-harness/tests/store.rs @@ -0,0 +1,836 @@ +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() +} + +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 new file mode 100644 index 0000000..a3e5fc2 --- /dev/null +++ b/crates/doc-harness/tests/store_layout.rs @@ -0,0 +1,171 @@ +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/documentation-pipeline.md b/docs/architecture/documentation-pipeline.md index 0b99c61..2c8986d 100644 --- a/docs/architecture/documentation-pipeline.md +++ b/docs/architecture/documentation-pipeline.md @@ -1,8 +1,10 @@ # Executable documentation pipeline User guides and their media are generated by running the application. A feature -owns one Rust scenario and one Markdown template, registered together in the -documentation harness. The same required `just docs check` verifies still images, +owns one Rust scenario and exactly one registered narrative source. The +hand-tool and navigation scenarios compose their text and typed resources in +Rust; the other 26 features retain Markdown templates. The same required +`just docs check` verifies still images, animations, bindings, assertions, and the complete generated file inventory. There is no manually maintained screenshot or animation path. @@ -36,13 +38,24 @@ captures to `.cache/docs/linux-vulkan-lavapipe/` and the receipt to see [verification](../development.md#verification-and-git-hooks). The implementation lives under [src/documentation](../../src/documentation/). -Feature scenarios are in [scenarios](../../src/documentation/scenarios/), their -Markdown sources are in [docs/templates](../templates/), and generated pages, +Feature scenarios are in [scenarios](../../src/documentation/scenarios/), retained +Markdown templates are in [docs/templates](../templates/), and generated pages, media, and the manifest are in [docs/guide](../guide/). Register a scenario module -in [mod.rs](../../src/documentation/mod.rs) and its feature/template pair in +in [mod.rs](../../src/documentation/mod.rs) and its narrative source in [features.rs](../../src/documentation/features.rs). Engineering decisions live here in `docs/architecture`; research stays separate in `docs/research`. +The [portable executable document boundary](executable-documents.md) describes +the maintained SDK, N3's `Guide` adapter, and the shared filesystem lifecycle. +N3 retains its real input, captures, legacy manifest, and exact renderer receipts. +For a migrated feature, `template: None` requires one completed Rust-authored +document. A feature with both sources, or neither, fails generation. + +`just docs build --out .cache/docs-candidate` builds fresh `reader/` and +`contributor/` trees from one replay. Contributor notes appear only in their +view; the reader output keeps the published format. This command refuses to +overwrite an existing candidate and does not accept or change a baseline. + The generated guide is user-facing. Its links must resolve within the guide; repository-owned, GitHub-hosted documents such as the root README, TODO, contributor instructions, and engineering notes may link to it, but the guide diff --git a/docs/architecture/executable-documents.md b/docs/architecture/executable-documents.md new file mode 100644 index 0000000..86c8416 --- /dev/null +++ b/docs/architecture/executable-documents.md @@ -0,0 +1,189 @@ +# 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 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 +exports reuse those frozen results; rendering another audience does not rerun the +application or recapture resources. + +The SDK validates typed handle ownership, evidence identity, resource use, +audience visibility, and generated references. Its renderer can export Markdown, +arbitrary resources, and a structured manifest. Text, JSON, images, or other +file types are artifact choices supplied by the consumer. The SDK does not infer +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 +reference. N3's [documentation pipeline](documentation-pipeline.md) owns product +replay, timing, captures, and renderer-specific evidence. This document records +the boundary between them. + +The current public exporter targets Markdown. Artifact producers are generic, +but there is no public structured document-view API for a different publisher +yet. Prove that interface with an actual alternate exporter before exposing it; +it must preserve audience filtering, typed references, observations, and resource +ownership together. The private document model is the extension boundary, without +a plugin registry or a promise of arbitrary presentation support today. + +## Project-owned configuration + +The SDK's layout is a default, not a required repository topology. `ExportLayout` +configures page and resource prefixes and exact per-document page paths. Complete +exports resolve typed resource links relative to their actual page locations; +authored local links must remain inside the exported bundle. Explicit +`resource_at` paths retain their bundle-relative names. Finishing a document +validates its intrinsic contract, while export validates the configured paths, +resource use, and audience-specific links before returning publishable bytes. + +`RunnerConfig` captures a caller-selected path base, retained output paths per +audience, and directories for those audiences under a build candidate. Destinations +may live outside the source project. `runner::run` supports direct test/build-tool +integration; `run_cli_with` is an optional argument adapter. The original `run_cli` +and `render_many` retain their defaults. There is no Cargo or Git discovery, and +no required source, scenario, fixture, website, or agent-skill directory. The host +owns generation and fixture/process setup. + +Configuration changes placement rather than integrity: manifest and ownership +metadata retain their protocol names, audience privacy and exact comparison stay +strict, all declared paths must be collision-free, and output trees cannot overlap. +That separation includes the adjacent lock, staging, and backup paths reserved by +each output's transaction. Artifact inventory paths use `/` on every host. +New audience trees must also have a provable path distinction before any private +bytes are written. Existing directory identities establish separation; missing +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) +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. + +## N3 ownership + +| Layer | Responsibility | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `Session` and the production application | Real input routing, fixture setup, witnessed controls, behavioral assertions, explicit time, rendering, and capture. | +| [Guide adapter](../../src/documentation/guide.rs) | Convert witnessed controls, canonical key labels, and captured N3 media into typed SDK bindings and resources. | +| SDK `Doc` and `Document` | Compose narrative, retain immutable evidence and contributor notes, validate handles, and render the selected audience. | +| [N3 artifact pipeline](../../src/documentation/artifacts.rs) | Select the registered narrative, assemble the existing guide tree and text manifest, validate guide-only links, and enforce renderer policy. | +| SDK `lifecycle` | Validate output paths and inventories, compare complete trees, stage updates, publish, and retain recovery material on interruption. | + +`Guide::control` requires that the same replay witnessed the control. Shortcut +labels come from the production binding catalog; requesting a label does not +deliver input or prove that its action ran. Scenarios still use +`Session::shortcut` and the normal input helpers for illustrated behavior. + +The adapter's capture methods delegate to `Session`. Registering already captured +media freezes its existing bytes without another render or clock advance. N3 +continues to distinguish stills from animations even though both use WebP. Before +finishing, the adapter checks that every captured media path was registered and +that each audience's referenced resources exactly match the captured paths and +bytes. N3's existing manifest requires every capture in the reader narrative, so +this adapter currently supports public captured media and private prose notes. +Contributor-only captures or additional non-capture resources fail explicitly. +That restriction belongs to N3's adapter; full SDK exports support private +resources and arbitrary artifact types. + +N3's established `Session::require` calls remain the behavioral assertions. +`Guide::finish` imports their successful execution into the SDK document and keeps +the existing fact text in N3's manifest. It does not replace replay with checks +against a manufactured final state. A Rust-authored scenario must finish exactly +one authored document, and its ID must match the registered feature. + +## Exactly one narrative source + +Every feature owns one scenario and one registry entry in +[features.rs](../../src/documentation/features.rs). The registry selects exactly +one source of user prose: + +| Registration | Narrative source | +| --------------------- | -------------------------------------------------------------------------------- | +| `template: None` | The scenario composes a `Guide` and finishes its SDK document. | +| `template: Some(...)` | A retained `.md.in` template binds the scenario's witnessed values and captures. | + +Both sources present or both absent is an error. Inventory checks reject orphaned +templates and scenarios. A source migration removes the old template instead of +keeping a second prose copy. Existing template-based features remain supported; +an unrelated behavior fix does not require a broader migration. + +The [hand-tool](../../src/documentation/scenarios/hand_tool.rs) and +[navigation](../../src/documentation/scenarios/navigation.rs) guides now author +their narrative in Rust alongside the scenario. The other 26 registered features +still use templates. Their eventual migration remains work to schedule and +review, not evidence that all N3 authoring has already moved to the SDK. + +For these two migrations, N3 uses `Document::render_fragment` to retain the +existing title, whitespace, media paths, and generated-file preamble. This mode +does not insert SDK page titles or block anchors. The N3 artifact pipeline still +validates links across the complete guide. Normal SDK `render_many` exports keep +their own complete-bundle validation. The existing N3 guide baseline, media, and +`manifest.txt` format are retained rather than converted to SDK manifest JSON. + +## Shared publication with explicit ownership + +N3 and the portable consumers share the SDK's `lifecycle` implementation while +retaining their own manifest and compatibility policies: + +- **Dedicated ownership:** N3's generated guide is a dedicated tree. Every + existing file is treated as owned output, absent candidate paths require + explicit retirement, and publication adds no `.ownership.json` file. This + preserves the established guide format. +- **Recorded ownership:** SDK `store` exports carry `.ownership.json` and the + structured SDK manifest. Their wrapper checks schema, audience, and retained + resource producer/profile compatibility in addition to exact tree contents. + +Generation, assertions, rendering, and candidate validation finish before +publication begins. Checking is read-only. Building needs a new destination. +Updating explicitly stages complete output before replacing the compatible +baseline. Ordinary publication errors preserve or restore the previous tree; +this is not a crash-atomic filesystem transaction. Interrupted transactions +retain recovery material and fail visibly. The SDK runner publishes each audience +destination independently. + +N3's `just docs build --out .cache/docs-candidate` performs one complete replay +and exports `reader/` and `contributor/` under a fresh candidate directory. Both +views render from the same completed sessions and frozen media; neither view +executes the application a second time. The two Rust-authored guides add +contributor notes, while the 26 retained template pages are identical in both +views. N3 prepares both subtrees and publishes them as one dedicated candidate +tree. The destination must not overlap `docs/guide` or `docs/baselines`, including +their ancestors or descendants. This review command does not accept or update +the canonical baseline. + +N3 keeps its renderer-profile check and separate lavapipe receipt policy. An +explicit `just docs update` can establish an intentionally changed native +baseline; checking never silently accepts a renderer change, skips images, or +adds tolerances. A successful update records bytes and still requires human +review. It does not approve product behavior, publication, or a release. + +## Verification and remaining validation + +`just doc-framework-test` exercises the portable SDK and both consumer examples +without N3's GPU harness. Each consumer checks retained Reader and Contributor +outputs. N3's `just docs check` and `just verify` retain real replay and exact +guide checks on the native renderer; the focused framework gate does not replace +them. + +The examples establish local integration with a CLI and an independently existing +Rust library. The serde_json guide is our authored integration, not external +maintainer adoption. Validation with independent project owners, the remaining +26 N3 source migrations, and longer-term API and schema compatibility remain +future work. The framework detects encoded behavioral and artifact drift; +prose clarity, meaningful coverage, visual legibility, and physical interaction +still need human review. diff --git a/docs/development.md b/docs/development.md index d890781..9b565e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,11 +1,13 @@ # Developing N3 The application is a single Rust package named `n3`, with private implementation -modules. Read [AGENTS.md](../AGENTS.md) for development principles and iteration +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 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 -spike has been removed; the root package is the maintained source tree. +spike has been removed; the root package holds the maintained application. ## Local commands @@ -408,10 +410,19 @@ runs them directly. The CI workflow becomes live when the repository is pushed. ## Documentation is executable Every user-guide feature has one registered Rust scenario under -`src/documentation/scenarios/` and one Markdown template in `docs/templates/`. -Registration lives in `src/documentation/features.rs`. User-facing state and -behavior come from the real workspace, semantic commands, native-navigation -adapter, and scene renderer. Assertions verify the intended outcome. +`src/documentation/scenarios/` and exactly one narrative source. Registration +lives in `src/documentation/features.rs`: `template: None` means the scenario +authors the guide through the N3 `Guide` adapter; `Some(...)` selects its retained +Markdown template under `docs/templates/`. The `hand-tool` and `navigation` +features now author their prose in Rust alongside the replay. The remaining +26 features still use templates. Do not maintain both forms for one feature. + +User-facing state and behavior come from the real workspace, semantic commands, +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. ```sh just docs @@ -430,20 +441,40 @@ just docs serve --no-open --port 3030 The preview reads the checked-in Markdown and WebP media without rebuilding the application, running GPU captures, or changing baselines. Run `just docs update` -in another terminal after changing a scenario or template, then refresh the -browser. `just docs update` regenerates through the native renderer; +in another terminal after intentionally changing the scenario's narrative or +its retained template, then refresh the browser. `just docs update` regenerates through the native renderer; `just docs check` verifies there without writing. Neither requires Docker. The small preview uses a vendored Docsify runtime, so it needs no network access, Node installation, or package-manager setup. It is a local reading surface, not a choice of framework for the eventual documentation website. +To inspect both audiences before updating the canonical baseline, build a fresh +candidate in ignored local storage: + +```sh +just docs build --out .cache/docs-candidate +``` + +This performs one complete replay, then writes `reader/` and `contributor/` +subtrees from the same completed sessions and captured bytes. The two +Rust-authored guides add contributor notes; retained template pages are identical +across audiences. The destination must be new and separate from `docs/guide` and +`docs/baselines`. This command does not accept a baseline or change the roles of +`just docs check/update`. + Review changed text, stills, and animated clips before accepting regenerated outputs. `check` does not rewrite or approve anything. The normal test suite replays all scenarios and checks the complete generated tree, including animation bytes and the manifest. Missing, orphaned, duplicate, and unreferenced artifacts fail. A generation failure writes no new baseline. -Templates render application shortcuts with `{{shortcut:semantic-id}}` as +Rust-authored guides take witnessed controls and canonical shortcut labels from +the `Guide` adapter, and insert typed handles into their authored Markdown. +Captures remain `Session` operations; the adapter registers their existing bytes +without replaying the action or advancing the clock. The current migrations keep +the published guide text, media paths, media bytes, and `manifest.txt` format. + +Retained templates render application shortcuts with `{{shortcut:semantic-id}}` as `` keys from the canonical input bindings, and witnessed menu paths with `{{control:id}}` as inline ``. Literal text-entry keys and gesture modifiers use `{{key:Name}}` and `{{modifier:name}}`. Do not copy remappable keys into prose @@ -470,6 +501,15 @@ 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`. It checks the maintained +SDK and the config CLI and serde_json consumers without starting N3's renderer. +This focused gate also runs inside `just verify`. +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`. + ## User settings Global preferences live at `~/Library/Application Support/N3/settings.json` on diff --git a/docs/templates/hand-tool.md.in b/docs/templates/hand-tool.md.in deleted file mode 100644 index c967252..0000000 --- a/docs/templates/hand-tool.md.in +++ /dev/null @@ -1,36 +0,0 @@ -# Panning with the hand tool - -Move the pointer over {{control:viewport}} and hold {{shortcut:navigation.pan}}. -The open-hand cursor shows that you can pan without changing your selected -tool. This works in object and vertex edit modes with View, Move, Rotate, -or Scale selected. - -{{image:hand-tool-ready}} - -Keep {{shortcut:navigation.pan}} held and click-drag with the left mouse button or -trackpad. The hand closes while you drag, and the view moves without rotating -or zooming. Your geometry and selection stay unchanged. - -{{image:hand-tool-drag}} - -This walkthrough shows {{shortcut:navigation.pan}} held during the pan, then released while the pointer keeps moving. - -{{animation:hand-tool-pan-and-release}} - -Release {{shortcut:navigation.pan}} to return to your previous tool. Panning stops -immediately, even if the mouse button is still down; its later release does -not select anything. - -Press {{shortcut:navigation.pan}} before starting the drag. A selection box or transform already in -progress keeps control if {{shortcut:navigation.pan}} is pressed afterwards. Apply or cancel a -pending [Move session](axis-locks.md) before using the hand tool, even between -drag releases. Text fields and popups -keep their keyboard input, and leaving the application clears the held hand -tool. Two-finger scrolling, pinching, and twisting retain their usual behavior. - -Hold {{shortcut:navigation.orbit}} instead to [orbit with the left button](orbit-tool.md). -{{shortcut:navigation.pan}} takes precedence if both modifiers are held before a drag. Once a drag -starts, another modifier cannot change its action. - -See [navigation](navigation.md) or [editing geometry](editing.md). -[Back to guide](README.md). diff --git a/docs/templates/navigation.md.in b/docs/templates/navigation.md.in deleted file mode 100644 index 99d9716..0000000 --- a/docs/templates/navigation.md.in +++ /dev/null @@ -1,88 +0,0 @@ -# Moving around - -Move the pointer over {{control:viewport}}: - -- Drag the **left mouse button** to draw a selection box. Selection updates when - you release; object bounds only need to intersect the box. With a - [transform axis lock](numeric-transforms.md), it previews the chosen Move, - Rotate, or Scale operation instead. -- Drag the **middle mouse button** to pan. -- Drag the **right mouse button** to orbit. -- Hold {{shortcut:navigation.pan}} with a left-button drag to [pan](hand-tool.md), or hold - {{shortcut:navigation.orbit}} with a left-button drag to [orbit](orbit-tool.md). - -These bindings work in object and vertex edit modes with every tool. Drag a -transform handle when you want to transform a selection. See -[selecting objects](object-feedback.md) and [editing](editing.md). -Apply or cancel a pending [transform session](numeric-transforms.md) before camera navigation -or background selection. - -{{image:navigation-mouse-orbit}} - -Press and release the right button within **{{value:mouse-drag-threshold}} -logical pixels** to open the viewport menu without moving the camera. It offers -{{control:viewport.frame}} and {{control:viewport.preferences}}. Once a right -drag crosses that distance, releasing opens no menu, even if the pointer has -returned to its starting point. -Context menus use the same flat rows and hover feedback as dropdown menus. -Their shortcut hints appear beside the commands, including selection, framing, -and Preferences. Commands become available as the selection and editing mode change. - -{{image:navigation-context-menu}} - -In **2D mode**, **two-finger scrolling pans** and keeps the 2D ruler visible in a -settled axis view. Hold {{shortcut:navigation.orbit}} and left-drag to orbit into 3D. -On a trackpad, this is a click-drag while {{shortcut:navigation.orbit}} is held. -{{modifier:shift}} + two-finger scrolling pans in either mode. - -{{image:navigation-aligned-trackpad}} - -In **3D mode**, two-finger scrolling orbits and {{modifier:shift}} with scrolling -pans. This applies even if the camera is still perfectly aligned or -orthographic. Choose the mode with the [gizmo's 2D/3D tabs](gizmo.md); clicking -an axis or choosing an axis preset enters 2D. -The 3D tab restores your previous 3D orientation and perspective by default, -keeping the current pan and zoom. Manual orbit continues from the visible -angle; it does not perform that recall. The gizmo preferences configure the -3D tab's return behavior. - -Pinching and a mouse wheel zoom the viewport. In **2D mode**, zoom keeps the point beneath -the pointer in place. {{modifier:command}} + two-finger scrolling also zooms -around the pointer; ordinary two-finger scrolling pans. In **3D mode**, zoom -stays centered on the camera target. - -A two-finger twist is ignored in **2D**, so a -twist during panning or pinching cannot switch the view to 3D. In **3D**, twisting -rotates the view. Panning -and zooming preserve the viewing direction. These trackpad bindings work in -object and vertex edit modes. Twisting is available only when the host exposes -rotation gestures. - -1. Press {{shortcut:view.frame}} or choose {{control:frame}} in {{control:view}} to fit and recenter - the model and restore its default viewing angle. - -{{image:navigation-frame-key}} - -2. Open {{control:view}} and point at {{control:view.top}} to find a preset. - Choose it, {{control:view.front}}, {{control:view.right}}, or - {{control:view.perspective}} to change the view with the current animation settings. - -{{image:navigation-view-menu}} - -For the separate top-row and numpad bindings, orbit steps, and fitting a -selection, see [number keys and fitting the view](view-keys.md). - -Click the {{control:projection}} cube button beside the gizmo's 2D/3D tabs, or -press {{shortcut:view.projection}} to switch between perspective and orthographic -projection. The icon shows the current projection; see the [gizmo guide](gizmo.md#projection) -for both states. {{control:edges}} and {{control:grid}} checkboxes control the -polygon boundaries and ground grid. - -Open {{control:preferences.open}} to find {{control:help.precise-scroll}}. -Enable it to make unmodified precise scrolling zoom in 3D mode. In 2D, -scrolling still pans, with or without {{modifier:shift}}; hold -{{modifier:command}} to zoom. - -3. Use the front view to inspect the model straight on. - -{{image:navigation}} diff --git a/justfile b/justfile index 2ce9c9a..c009b1e 100644 --- a/justfile +++ b/justfile @@ -157,13 +157,13 @@ fmt: fmt-rust fmt-docs # Format Rust source. fmt-rust: - cargo fmt + cargo fmt --all # Check all supported formatting without changing files. fmt-check: fmt-rust-check fmt-docs-check fmt-rust-check: - cargo fmt --check + cargo fmt --all --check # Format authored sources; generated guides remain owned by their pipeline. fmt-docs: @@ -172,14 +172,21 @@ fmt-docs: fmt-docs-check: python3 tools/format_docs.py --check -# Check all targets with warnings denied. +# Check every workspace package and target with warnings denied. lint: - cargo clippy --locked --all-targets -- -D warnings + cargo clippy --locked --workspace --all-targets -- -D warnings # Run the complete suite, including exact docs replay, on the native host. 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: + cargo test --locked -p executable-docs -p doc-example-config --all-targets + cargo test --locked -p executable-docs --doc + RUSTDOCFLAGS='-D warnings' cargo doc --locked -p executable-docs --no-deps + # Open the local guide preview, check it, or intentionally regenerate it. docs mode="serve" *args: #!/usr/bin/env bash @@ -192,9 +199,10 @@ docs mode="serve" *args: exit 2 fi ;; - *) echo 'Usage: just docs [serve [--no-open] [--port PORT] | check | update]' >&2; exit 2 ;; + build) ;; + *) echo 'Usage: just docs [serve [--no-open] [--port PORT] | check | update | build --out PATH]' >&2; exit 2 ;; esac - exec cargo run --locked -- --docs "$1" + exec cargo run --locked -- --docs "$@" # Test the local preview server without a browser or GPU. docs-preview-test: @@ -238,4 +246,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 test benchmark-rust-test +verify: fmt-check lint tools-test doc-framework-test test benchmark-rust-test diff --git a/rfcs/0003-executable-documentation-framework.md b/rfcs/0003-executable-documentation-framework.md new file mode 100644 index 0000000..b3baed1 --- /dev/null +++ b/rfcs/0003-executable-documentation-framework.md @@ -0,0 +1,894 @@ +# RFC 0003: A reusable executable documentation framework + +Status: **Implemented locally — 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. + +Authoring requirements: concise, honest authoring and scenarios that write the +document's text, resources, and explicit contributor notes themselves. The primary +model below is an executable document; separate text templates are an optional +integration or migration facility. The maintained SDK README is the source for +current syntax; the findings below distinguish implemented behavior from the +broader design and remaining adoption questions. + +## Recommendation + +Build a Rust-first framework for documentation whose important promises are +connected to executable scenarios and retained evidence. Ship a small SDK and +runner interface first. An optional skill explains the workflow; the framework +works without an agent, skill, particular editor, or documentation website. + +The unit of adoption is a **documented capability**: an explanation of something +a developer wants users to understand and continue relying on. Its contract +connects authored intent, real execution, selected assertions, and the evidence +presented to the reader. A capability can produce values, command transcripts, +structured data, file examples, diagrams, images, or animation. A renderer is one +possible producer, not the framework's organizing abstraction. + +Authors express that capability as an **executable document**: ordinary Rust +that composes headings, paragraphs, resources, and contributor notes alongside product actions and +checks. A reader's explanation and its demonstrated behavior are authored +together. The framework handles evidence registration and reference bookkeeping +without requiring a separate template or a duplicate list of resource names. + +The development loop is: + +1. Describe the intended experience, including relevant failure or recovery. +2. Exercise the real behavior and assert the promises worth preserving. +3. Compose the explanation with observed names, checked results, and captured + output directly in the document function. +4. Generate the documentation and inspect it as a reader. +5. Retain the reviewed output and fail future checks when the contract drifts. + +This complements unit, property, integration, and performance tests. Developers +choose which behavior deserves a documented contract; internal implementation +details and exhaustive edge cases need not become user tutorials. The value is +that adding a documented feature also leaves an executable account of its intent. +Source control retains the history; the framework need not become an archive +service or prescribe that every run be stored forever. + +## Pre-extraction N3 baseline + +The pipeline and development skill established the working precedent. The +following observations describe N3 before this extraction; the linked source +files now contain the maintained implementation described later in this RFC. +See the [current pipeline](../docs/architecture/documentation-pipeline.md) and +[development skill](../.agents/skills/docs-driven-development/SKILL.md) for the +updated contract. + +| Current source | Useful foundation | Coupling or limitation | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| [Feature registry](../src/documentation/features.rs) | Stable feature identity, scenario, and narrative registration | One N3 scenario and template per page | +| [Session](../src/documentation/mod.rs) | Real interactions, assertions, values, control witnesses, captures | Owns N3 workspace state, egui, input, and capture | +| [Artifact pipeline](../src/documentation/artifacts.rs) | Reference resolution, unique paths, complete inventory, exact comparison, explicit update | Constructs a GPU capture before running any feature; templates know N3 controls and WebP | +| [Replay and animation](../src/documentation/tutorial.rs) | Explicit time and meaningful intermediate states | N3 input and frame sampling policy | +| [Capture](../src/documentation/capture.rs) | Production rendering and separate UI-only capture | wgpu, egui, scene rendering, window composition | +| [Renderer receipts](../src/documentation/renderer_baseline.rs) | Independent exact baselines tied to the published guide | N3 renderer profiles and output paths | + +The artifact collection already stored paths and arbitrary bytes. The reusable +seam was collection, validation, composition, and verification of evidence. N3's +split between scenario and text template was an existing authoring choice, +not a constraint to reproduce in the SDK. +Exporting `Session` would export much of N3 with it. + +Several stronger properties required new work at that point: + +- `Session::require` recorded a successful assertion as a string. `value` inserted + a string independently. There was no typed claim-to-value relationship. +- At least one assertion is required per feature. That does not establish that + every sentence, value, or image is meaningfully covered. +- Generation and validation finished before publication, but files were then + written sequentially. An I/O failure could interrupt an update without the + staged replacement and recovery now provided by the shared lifecycle. +- The manifest identifies a broad renderer profile. It is not a complete record + of all driver, dependency, and execution-environment inputs. + +Preserve the existing guarantees during extraction. Treat stronger provenance, +publication recovery, and broader output support as explicit additions. + +## The contract model + +Keep the model small enough to author without a specification bureaucracy: + +| Concept | Meaning | Example | +| ----------- | ------------------------------------------------------------------ | -------------------------------------------------- | +| Narrative | Human-authored purpose and explanation | "An explicit port overrides the default." | +| Scenario | Code exercising behavior within an executable document | Parse configuration through the public library API | +| Claim | A stable identity for a selected promise | `explicit-port` | +| Check | An executed assertion supporting that claim | Parsed port equals the supplied port | +| Observation | A value or name obtained during execution | A port value or witnessed UI label | +| Artifact | Retained output with identity and format | JSON result, transcript, PNG, SVG, or WebP | +| Note | Authored explanation for contributors, excluded from reader output | Rationale for an assertion or capture choice | +| Document | Authored blocks, references, resources, and contributor notes | Reader and contributor views of the same document | +| Baseline | Output retained for subsequent comparison | Reviewed pages, assets, and manifest | + +A named assertion creates a claim record; authors need not repeat every sentence +in a separate claim file. Checked-value handles connect rendered values to the +assertions that produced them. More general assertions can return claim handles +to which artifacts are explicitly associated. Stable IDs belong to a contract +namespace; titles and file paths can change without silently changing identity. +These are internal relationships and a few authoring primitives, not a checklist +of separate objects the author must manually wire together. Literal prose needs no +ID. Declare stable IDs once for contracts, claims, and retained resources; reuse +their handles. Do not derive persistent IDs from line numbers or call order. + +The evidence manifest records these relationships. It can establish that a +required check ran, that a value came from that check, and that an artifact was +emitted during the scenario. It cannot establish that the assertion is sufficient, +that a picture proves a claim, or that arbitrary prose is true. Those remain +authoring and review responsibilities. A snapshot alone is an observation, not a +behavioral assertion. + +Bindings also need an honest origin. A label read from a production registry and +a control actually witnessed in a running UI are different evidence. Preserve +that distinction without requiring every project to have UI controls. + +Each runnable contract requires a successful behavioral check. Introductory prose +and navigation can compose these contracts without inventing assertions of their +own. Several scenarios may support one page; the framework should not bake N3's +one-feature/one-page convention into artifact identity or the saved manifest. + +## Product and ownership boundaries + +Start with **one unpublished Rust library** with focused modules for document +composition, evidence, manifests, and verification. Add a small runner API to the +same package. Separate packages only when dependencies or consumers justify them. + +| Framework owns | Adopting project owns | +| ------------------------------------------------------------ | ---------------------------------------------------------- | +| Registration, scoped identities, and execution results | Which capabilities deserve documentation | +| Claim/check records and artifact reference accounting | Meaningful assertions and fixture preparation | +| Checked values, typed inline references, and document blocks | Public API calls, CLI execution, or application automation | +| Artifact inventory, hashes, and declared producer profiles | Rendering, capture, encoding, and format semantics | +| Deterministic document composition and baseline comparison | Documentation hierarchy, prose, branding, and hosting | +| Read-only check and explicit update interfaces | Review decisions, CI invocation, and optional agent skills | + +Use ordinary Rust functions or closures. Projects may capture their existing +driver or construct it inside a scenario. Avoid a universal `World`, `Driver`, +`Renderer`, scheduling engine, or scenario language until a real integration +requires one. Rust is the first scenario-authoring language; a Rust runner may +exercise a non-Rust CLI, but that is not a second language SDK. + +The core accepts produced output through a narrow artifact API: contract/local +ID, media type, bytes or a staged file, producer identity, comparison profile, +and links to checks. Streaming or staged files prevent arbitrary media from +requiring a complete in-memory copy. Small text artifacts can use byte helpers. +Format-specific metadata and validation belong to producers; accepting bytes +does not make the framework a validator for every possible file format. + +The document model contains ordered blocks: headings, paragraphs, Markdown prose, +code, resources, and contributor notes, with room for lists and tables when the examples need them. +Paragraphs accept literal text and typed inline references to checked values, +production labels, or resources. The first presentation adapter renders these +blocks to ordinary Markdown and assets. Unknown formats can be linked as files; +inline display requires an explicitly supported presentation. Embedding text must +escape Markdown correctly, including content containing backticks. An invalid +reference or incompatible resource kind fails. +Resource links within raw Markdown still participate in final reference and +ownership validation; literal markup is not an escape from those checks. + +Document composition observes completed execution. Appending a paragraph or +placing an already captured resource must not advance time, change input, or +reevaluate a mutable application value. Checks retain immutable observed values; +captures retain their original checkpoint. Rendering the finished document must +not rerun the application. If a later state matters, capture or check it again +explicitly. Source execution order and emitted block order are deterministic. + +Only explicit reader-content calls emit reader content. Contributor notes are +authored explicitly too, but have a different audience. Actions and assertions +do not automatically become step lists, passing-test labels, or execution logs. +Authors choose how to explain the experience, can perform setup silently, and +can attach additional regression checks without changing the narrative. Failed +execution invalidates the whole candidate even if earlier paragraphs were built. + +Long static passages can use a single multiline Markdown block, or an optional +included prose file. A compatibility template adapter can consume the same typed +records during N3 migration. Neither creates a second required authoring source +or an independently maintained list of resource references. + +Keep the document blocks and verified record available independently of Markdown +rendering so an adopter can supply another formatter later. Do not build a plugin loader or +second site generator now. Custom formatters must declare their consumed +references so they cannot bypass ownership checks by silently dropping evidence. + +N3 keeps its production input replay, explicit clock, control witnesses, +annotations, window composition, image encoding, and strict renderer policy as +its adapter. No graphics dependency belongs in the portable library's default +dependency graph. The application stays one application crate; a proven reusable +documentation dependency is a separate, justified boundary. + +## What adoption could look like + +A developer adds one documentation runner to an existing Cargo project, registers +a document function, then calls the runner locally and in CI. They do not +need to restructure their product or move their entire manual into the framework. + +The following is an API sketch, not compiling code or an existing package: + +```rust +fn configuration(doc: &mut Doc) -> Result<()> { + doc.heading("Configure the service"); + doc.markdown("Set `port` to choose the listening port."); + + // Call the adopting project's real public API. + let config = my_app::Config::parse(r#"{"port":8080}"#)?; + let port = doc.expect_eq("explicit-port", config.port, 8080)?; + doc.paragraph(("This example selects ", &port, ".")); + doc.code("configuration", Json(&config))?; + Ok(()) +} + +fn main() -> ExitCode { + doc_harness::run_cli([Document::new("configuration", configuration)]) +} +``` + +The emitted page reads as ordinary documentation: + +```markdown +# Configure the service + +Set `port` to choose the listening port. + +This example selects 8080. + + {"port":8080} +``` + +The paragraph consumes a checked-value handle directly. The framework records +that reference without requiring a second value name or a template placeholder. +The `code` convenience method registers and places one JSON resource in a single +operation. Only the port was asserted; showing the complete JSON does not label +all its fields as verified. A lower-level resource API returns reusable handles +when authors need a resource in several places or an explicit claim association. + +For UI documentation, the same model can compose a sentence from a shortcut +handle, use that identity to replay input through the application adapter, and +place the resulting image directly. An illustrative fragment is: + +```rust +let pan = app.shortcut_ref("navigation.pan")?; +doc.paragraph(("Hold ", &pan, " while dragging to pan the view.")); +// The adapter replays the gesture; checks still assert the meaningful result. +pan_example(doc, app, &pan)?; +doc.image("after-pan", app.capture()?, "The view after panning.")?; +``` + +`pan_example` is a project helper, not a magical framework action. Its gesture, +intermediate assertions, and release behavior remain inspectable ordinary code. +The shortcut handle records its production identity and provenance; putting it +in a paragraph alone does not establish that it was exercised. The image helper +registers and places the captured resource, deriving media metadata from the +producer. The author supplies its identity and description once. + +These signatures illustrate the authoring contract; the spike must establish +usable ownership, borrowing, error propagation, and diagnostics. Methods that +only append blocks may defer validation to finalization; checks and producers +that can fail immediately return errors. No error is silently discarded. +A second scenario should exercise malformed configuration and meaningful error +behavior. For a CLI guide, execute the actual CLI and check its exit status and +output; calling an internal parser alone would not demonstrate CLI behavior. + +## Contributor notes and rendering modes + +Internal notes are authored document content that can be retained and shown to +contributors. They are separate from Rust source comments, assertion diagnostics, +and application commands. Use them for rationale, limitations, review questions, +or an explanation of why a particular state is demonstrated. They do not count +as behavioral checks or convert proposed behavior into verified promises. +Notes reuse the same prose and typed-reference composition as paragraphs, so +they can refer to resources without a second rich-text authoring API. + +Keep authoring small; illustrative additions to the existing model are: + +```rust +doc.note("Keep the malformed-input case: recovery is part of this promise."); +let image = doc.image("after-pan", app.capture()?, "The view after panning.")?; +doc.note_on(&image, "Inspect label legibility when changing the capture scale."); +``` + +An ordinary note belongs to its authored location in the document. An anchored +note refers to an existing block, claim, or resource handle; it need not repeat a +path or resource name. Dangling and cross-run targets fail validation. Plain +paragraphs and notes do not need manually assigned IDs; an explicit stable anchor +is available when a consumer needs to refer to a particular passage across runs. +Do not promise persistent comment identity from line numbers or insertion order. + +Execute once and derive audience-specific views from the same immutable document +and evidence. Rendering mode must not alter actions, checks, application state, +or the reader narrative. A note-only edit leaves reader output bytes unchanged. + +| View | Content and publication boundary | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reader, the default | Reader prose and resources only. Omit note bodies, note metadata, and resources referenced exclusively by notes from the exported files and manifest. | +| Contributor, explicit opt-in | The same reader content with contributor annotations and their resources. A plain renderer can show callouts; a future viewer can toggle comment markers or a margin panel. | + +Use separate output roots and inventories for the two views. Internal notes must +not be carried in the reader export as hidden HTML, comments, embedded JSON, or +unused files. A contributor viewer can include a toggle because its bundle was +explicitly generated with those records; a reader export has nothing to reveal. +The adopter decides where to publish each view and any access controls. +Each export identifies its audience in the manifest; comparing a contributor +result against a reader baseline fails as an incompatible view. + +Validate all references before rendering, then compute the resource inventory +reachable from each view. A resource shared by reader prose and a note remains a +reader resource; one referenced only by a note is contributor-only. Neither is +an orphan. Linking contributor-only evidence to a reader-visible claim does not +promote it into reader output: presentation use determines visibility, and each +export's metadata includes only relationships within that audience. Complete +internal reference validation and behavioral checks still run. +If contributor output is retained as a baseline, compare it in its own +scope; its note changes do not create public artifact drift. Notes must not affect +reader numbering, anchors, asset identities, or ordering. + +The first proof needs note records and reader/contributor export tests, with a +simple contributor rendering. An interactive comment UI, author accounts, +threads, notifications, and resolution workflows are later viewer features. +This is one document with two views, not a second maintained contributor guide. + +## Future revision and diff views + +Design for an optional viewer that compares two document/evidence bundles. A Git +integration could supply bundles from two revisions, while another integration +could use local baselines or build artifacts. Git selection and loading belong +outside scenario execution and outside the portable document model. + +The existing boundaries should supply enough information: stable document and +resource identities, explicitly named anchors where needed, versioned manifests, +media types, content hashes, comparison profiles, and access to the retained +artifact bytes. Text can be compared as rendered text or structured blocks; +images can later use side-by-side, overlay, or difference views. Exact prose +block matching across revisions is not guaranteed for unanchored text. Changing +a resource's stable ID appears as removal/addition unless an explicit mapping is +supplied; changing its title or path while retaining the ID preserves identity. + +A comparison viewer must work with the artifacts actually retained for each +revision. Missing historical captures are reported as unavailable; reconstructing +them requires a separate explicit run. A Git commit alone is not proof that its +documentation was executed or reviewed. Source revision remains optional run +metadata, preserving builds outside Git and avoiding commit-dependent output. + +Preserve three small data and consumer boundaries: + +- Composition produces an immutable document/evidence result. +- Audience rendering selects the applicable records and resources. +- A future revision source supplies two such results to a comparison viewer. + +Do not add Git commands, repository lifecycle management, image-diff algorithms, +a revision-provider trait hierarchy, dynamic plugin loading, or a viewer SDK now. +Ordinary Rust modules and consumers of the versioned records suffice. Visual +diffs help human review; they do not alter exact check policy or approve a +baseline. Revision views follow the same audience rules as ordinary exports. + +## Running the document + +An initial project-owned `examples/docs.rs` runner could expose: + +```sh +cargo run --example docs -- check +cargo run --example docs -- build --out target/docs-review +cargo run --example docs -- update +``` + +| Command | Proposed behavior | +| -------- | --------------------------------------------------------------------------------------------- | +| `check` | Execute, validate, and compare against the retained baseline; never change it | +| `build` | Execute and write a validated candidate to an explicit review directory; do not accept it | +| `update` | Execute, validate, and deliberately replace owned baseline output; never imply human approval | + +An example or dedicated runner package works with an existing public library +API; these are separate crates and cannot access private application types. +For private integrations such as N3's, keep an in-crate module, test, or gated +application runner. Use development dependencies or explicit feature gates where +appropriate to keep documentation-only dependencies out of shipped runtime code. +A normal Rust test can invoke the same check API. A future `cargo` subcommand may +discover and launch the configured runner; it cannot execute arbitrary project +Rust callbacks without compiling them. Distribution convenience follows a useful +SDK, rather than dictating its architecture. + +## Authoring ergonomics is an acceptance criterion + +Aim for a readable account of the experience with little framework bookkeeping. +Line count is a useful symptom, not the objective. Count the full authored cost: +document functions, prose, helper definitions, fixtures, registration, and +adapter configuration. Moving hundreds of lines into a one-use helper does not +establish a reduction in complexity. + +The current [navigation scenario](../src/documentation/scenarios/navigation.rs) +and [editing scenario](../src/documentation/scenarios/editing.rs) are each over +900 lines; [gizmo](../src/documentation/scenarios/gizmo.rs) exceeds 800. These +files include multiple states and regression cases, not just one screenshot's +setup. Preserve their relevant coverage while addressing the actual repetition: + +| Authoring cost observed in N3 | Proposed response | Behavior that must remain visible | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Capture names repeated in scenarios and templates | Register and embed in one call; reuse typed resource handles | Which state was captured and where the resource appears | +| Double-click event sequences repeated in editing, axis locks, and numeric transforms | One tested adapter gesture helper with explicit timing policy | Both press/release pairs and ordering through production input | +| Repeated cloning and comparison of document, selection, revision, and dirty state | Named project-specific observation/check helpers with field-level failure details | Exactly which fields must stay unchanged; avoid accidental whole-application snapshots | +| Repeated callout, capture, and clear sequences | Scoped presentation helpers where lifetimes match | Presentation cannot alter focus, held input, or application state | +| Repeated input matrices and setup | Ordinary functions and data-driven cases with distinct case IDs | Every mode and recovery path still executes and fails independently | + +Keep high-level intent and important intermediate checkpoints readable in the +document function. Factor mechanical event construction and established invariant +checks into the owning adapter. An abbreviated `drag` helper is useful for a +completed drag; a release-order example needs explicit held and released phases. +Do not hide those phases or replace production input with a final-state mutation. + +Prose should remain comfortable to edit: whole paragraphs and multiline Markdown +are first-class. Authors should not construct an object for every word or sentence. +For dynamic inline content, prototype ordinary tuple/sequence composition first; +consider a small typed formatting macro only if the examples establish a clear +benefit. Ordinary `format!` or `Display` conversion must not silently discard a +checked value's provenance and then present it as a verified inline reference. +Keep Rust for control flow; do not introduce an assertion or scenario DSL just to +save punctuation. + +Evaluate ergonomics on two tasks before settling the API: write the small +non-graphical guide from scratch, and port a representative section of a large +N3 scenario with its narrative. Review all author-written files together. Check +that each durable ID is declared once, adding a paragraph or resource needs no +parallel registry edit, and a behavioral failure identifies the document, claim, +expected/actual result, and relevant helper/case rather than an opaque boolean. +Compare the code and generated guide side by side; retain the same checks, +intermediate states, and resource content. Record duplication and maintenance +cost as well as total lines, with no arbitrary line-count target. + +## Drift, determinism, and publication rules + +The portable default is exact comparison of the complete declared output set. +Text and JSON producers serialize deterministically. N3 continues its exact +renderer-specific checks. There is no automatic fallback to skipping an artifact, +accepting a new baseline, or relaxing image comparison after a failure. + +An adopter may eventually need a named, versioned normalization or semantic +comparison policy. Add that only for a demonstrated use case, record it per +artifact, and expose it during review. Do not ship image tolerance or a general +custom-comparator system in the first proof. Volatile output should first be +controlled through fixtures, clocks, seeds, and explicit adapter policy. + +Separate the stable baseline manifest from diagnostic run metadata. The former +contains a schema version, contract/check/reference identities, artifact paths, +media types, sizes, hashes, and declared producer/comparison profiles. The latter +can record the source revision, actual host details, timings, and local paths +without making every unrelated commit or run timestamp create document drift. +Unknown schema versions or incompatible required profiles fail visibly. A profile +is a declared compatibility contract, not proof of every environmental input. + +Minimum failure rules: + +- A required scenario, assertion, producer, or document-composition failure fails the run. + A panic also prevents baseline publication; publishing a passed subset is not + a successful complete check. +- Duplicate IDs, unsafe relative paths, duplicate output ownership, unresolved + references, wrong artifact kinds, missing files, and changed bytes fail. +- Every reader-facing evidence artifact must be referenced by a document. + Supporting evidence retained outside the reader's page must be explicitly + associated with a claim in the manifest. Generated pages, navigation, and the + manifest have declared structural roles and ownership; they are not treated as + evidence attachments. None of these roles permits arbitrary orphaned output. +- Contributor-note references participate in validation and ownership for the + contributor export. Reader exports contain only their own reachable inventory; + omitting contributor records must not skip behavioral checks or hide failures. +- Checks cover only the explicitly owned output root. The framework neither + scans nor takes control of the adopter's whole documentation site. A generated + subtree can coexist with handwritten pages. Local references outside that + subtree require a declared site root or asset mapping; remote link reachability + is a separate concern. N3 retains its stricter guide-only link policy. +- An obsolete owned output is reported, not silently deleted. Renames and + retirements require explicit reconciliation. Unknown files and symlink escapes + must never be overwritten or removed by baseline update. +- All execution and validation complete before baseline writes. Stage candidate + output separately. Concurrent update and interrupted/I/O-failed publication + must have a tested recovery policy before adoption; never describe sequential + file writes as an atomic transaction. + +The runner executes project code, so `check` can promise a read-only baseline, +not a universally side-effect-free application. Projects isolate their own +fixtures, settings, filesystem effects, and external services. The core does not +implicitly introduce network calls, capture user state, or publish a website. + +Generation, human review, baseline update, and deployment are separate actions. +The first release candidate can use ordinary Git diffs and existing viewers for +review; a mandatory hosted review UI or stored approval system is unnecessary. + +## Incubation and implementation sequence + +All stages stay in this repository. Timings and public API stability remain open. +Do not change the existing N3 guide baseline merely to make extraction convenient. + +| Stage | Deliverable | Exit evidence | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Prove the contract and authoring model without graphics | An ignored standalone Cargo spike with a tiny core and independently compiled configuration library/CLI example | One executable document composes readable prose, checked values, JSON, and a transcript without a separate template; authoring costs and deliberate failures are reviewed | +| 2. Establish a maintained boundary | Promote only the proven core into one unpublished package, provisionally `crates/doc-harness/`, with the small consumer as an example/test fixture | Dependency graph contains no N3, egui, wgpu, or window system; focused failure tests pass; maintained code does not depend on ignored files | +| 3. Bridge one N3 guide | Adapt one existing scenario and its bindings/captures to the same core; compare through N3's existing publication format | Identical behavior, Markdown, media, and legacy manifest; existing renderer receipts remain valid; `just verify` passes | +| 4. Migrate and test real adoption | Move the remaining shared lifecycle behind the adapter, and trial one capability in an independently existing Rust project | Full N3 native guide and Linux receipt checks pass; adopter adds a scenario without changing core internals or creating a renderer | +| 5. Prepare the future repository | Package the SDK, runner instructions, executable getting-started guide, extension example, and optional workflow skill | Relocation works without N3 paths, private assets, or its CLI; package API, license/provenance, and schema policy have been reviewed | + +Stage 1 is a portability experiment, not evidence of external demand or a stable +SDK. Stage 4's independent project trial is needed to validate adoption rather +than merely demonstrate a second fixture we designed ourselves. A release or +repository move is a later decision, not a side effect of these stages. + +Use `spikes/executable-docs/` for Stage 1 after confirming it is ignored. Its own +Cargo project must not alter N3's manifest, lockfile, production imports, or +verification recipes. Keep its guide, review captures, and findings together. +The first implementation task is deliberately bounded: + +1. Implement document-function registration, named equality/condition checks, + checked values, and text/JSON resources. +2. Implement the minimal document composer used by the example: Markdown blocks, + typed inline references, direct resource placement, and contributor notes. + Render reader and contributor Markdown/assets to separate owned directories + with audience-specific manifests; a plain note callout is enough initially. +3. Expose the same runner through a Rust test and `check`, `build`, and `update`. +4. Demonstrate failures for changed behavior, an invalid/cross-run reference, an altered + or missing artifact, duplicate identity, orphan output, and failed generation + leaving the retained baseline unchanged. +5. Read the generated guide and perform a deliberate feature change: update the + prose and assertions, inspect the candidate diff, then update the baseline. + Confirm that checks alone do not emit reader prose, presentation does not + change application state, and resources/values retain their observed state + after later application changes. Record authoring friction and revise the + proposed API before promoting it. + +The example should include a multi-paragraph explanation, a failure section, a +checked inline value, a resource reused through its handle, and a shared helper. +Those cases make the syntax and ownership costs reviewable before extraction. +Add a note attached to a resource and one contributor-only resource. Verify note +inclusion in contributor output, complete exclusion from the reader bundle, +reader byte stability after a note-only edit, and rejection of invalid note +targets or mismatched export audiences. No interactive viewer or Git integration +is required for this stage. + +For Stage 3, use a bridge formatter to preserve N3's current manifest and paths +while checking the new structured records separately. A shared-core extraction +must not force a new baseline format merely because the portable SDK has one. +Any intentional N3 output change gets its own explained migration and follows +the normal regeneration and visual review workflow. + +The bridge started with [the hand-tool scenario](../src/documentation/scenarios/hand_tool.rs) +and its former template, now removed. Together they exercised +production input, held/released state, control and shortcut bindings, stills, and +animation. This checks substantially more than copying one screenshot through +the new artifact API. + +Then author that same guide directly in the document function, preserving the +reader output and assertions. Before broader migration, also port one substantial +section of the larger navigation or editing guide to assess the ergonomics under +real complexity. A compatibility adapter is a transition aid; it must not leave +every author maintaining both a text template and document-building code. + +The migration must not create two permanent artifact ownership or verification +paths. Initially compare old and candidate behavior; once parity is established, +make the existing `just docs` and `just verify` commands use the shared lifecycle. +N3's capture and semantic input assertions remain required throughout. + +Measure cold/warm documentation build time, run time, and dependency impact on +both consumers before making performance or convenience claims. Serial execution +is enough initially; concurrency and caching wait for measured need. + +## Stage 1 findings (2026-10-04) + +The ignored `spikes/executable-docs/` workspace now implements and exercises the +first stage. Its `README.md` gives reproducible commands; `VALIDATION.md` records +the checks, rendered review, and authoring assessment; `PORTABILITY.md` records +the independent rebuild. These local files are deliberately not linked as +repository-hosted documentation because ignored experiments are not published. +N3's application, Cargo manifests, lockfile, guide, and recipes are unchanged. + +The prototype has two local packages: a small `executable-docs` SDK and an +independently compiled configuration library/CLI. A normal Rust function writes +multiline Markdown, paragraphs with typed checked values, JSON and command +transcripts, and contributor notes. It exercises the actual CLI through valid +save, invalid replacement preserving every output byte, and successful retry. +The same function is called from the runner and a regression test. No template, +renderer, agent skill, or graphics dependency is needed. + +The working syntax uses `Doc::new`, `expect_eq`/`require`, `paragraph`/`markdown`, +`resource`/`code`, `note`/`note_on`, and `finish`. Tuples compose literal text and +typed handles; `text`, `json`, `text_code`, and `json_code` cover common resource +cases. Exact captured bytes can retain application-defined producer/profile +metadata through `Artifact`. These names are experimental. The examples earlier +in this RFC remain design sketches, not an alternative implemented API. + +Both audiences render from one frozen execution. Reader output excludes notes, +their relationships, and exclusive resources throughout pages, assets, and +manifests. Independent tests compare the complete reader bundle before and after +note insertion, including annotations attached to existing checks. A successful +check retains an ID/kind/pass record; actual/expected payload is exposed to a +reader only when public prose references the checked value. Check IDs are public +contract metadata. A private diagnostic resource referenced only by a note is +entirely absent from the reader bundle. + +The runner exposes read-only `check`, new-directory `build --out`, and explicit +`update`. It renders all selected audiences before writing. Each audience owns a +separate inventory and filesystem transaction. It rejects unknown files, +orphaned resources, invalid/cross-run handles, unsafe paths, wrong resource kinds, +duplicate IDs, audience/schema mismatch, and incompatible producer/profile +changes for retained resource IDs. New resources may introduce new producers. +Retired files require explicit reconciliation. Staging, locking, adjacent rename, +rollback, and explicit backup recovery are tested. Publication is not atomic +across audiences or guaranteed crash-atomic; a postpublication cleanup error +reports that the valid candidate was already published. + +Validation includes 46 prototype tests, a standalone rebuild outside N3, exact +comparison of both relocated exports, and browser inspection of the rendered +reader and contributor views. N3's full native `just verify` gate also passed, +including its required exact documentation replay. The initial contributor rendering exposed an +ambiguous “note on 2” label; the implementation now names the check instead. +A copied consumer then changed its default port from 8080 to 8181. The old +assertion failed with document/check IDs and actual/expected values, preserving +baseline bytes and modification times. Updating the prose and assertion produced +a candidate diff; review, explicit update, and a subsequent exact check completed +the change cycle. The main demonstration retains its original default. + +The authoring cost is still material: + +| Measured scope, including comments and blank lines | Lines | +| -------------------------------------------------- | ----: | +| Narrative/scenario function | 140 | +| Supporting consumer helpers | 74 | +| Documentation runner | 22 | +| Documentation regression tests | 48 | +| Consumer Cargo configuration | 13 | +| Complete documentation integration | 297 | +| Example application and its own tests | 300 | +| Complete consumer | 597 | + +This is evidence that one authoring source can cover prose, execution, evidence, +and notes. It is not evidence of a percentage reduction against N3's larger +scenarios. No complexity was excluded merely by moving it into helpers. Repeated +transcript registration/placement and sparse boolean-failure context are concrete +remaining friction. Preserve explicit behavioral assertions while refining those +operations with the next real consumer; do not solve them with a large DSL yet. + +Fresh-target compilation in the relocated workspace took about 8.0 seconds; a +warm build of both documentation views took about 0.58 seconds. These are single +local observations with a warm registry cache, not performance claims. The copy +used the host's Rust 1.92.0 rather than N3's inherited 1.98.1 and produced the same +19 files / 25,335 bytes, including ownership markers. This proves relocation on +this Mac, not Windows/Linux portability or a supported minimum Rust version. + +Keep these limitations visible before promoting an API: + +- Public prose block IDs are positional; note insertion cannot renumber them, + but public paragraph insertion can. Document/check/resource IDs are authored + and stable. Durable block identity/source spans remain a future diff concern. +- Raw authored HTML is rejected; reference definitions resolve within one + Markdown block; local fragments target generated block IDs. External links + receive basic syntax checks, not network availability checks. +- The filesystem lifecycle assumes cooperative local writers; it is not a + security boundary against a process racing filesystem operations. +- The non-GUI example was created alongside the SDK. It proves mechanics, not + independent demand, adoption success, or ergonomics for a thousand-line guide. + +At the end of Stage 1, the next proposed work was the maintained lifecycle +boundary and two N3 authoring trials. The subsequent user authorization and +results are recorded below. Git adapters, interactive comments/diffs, plugin +infrastructure, publication, and the repository move remain deferred. + +## Maintained implementation findings (2026-10-04) + +The local implementation now includes: + +- [An unpublished SDK](../crates/doc-harness/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 + existing file format; recorded trees retain explicit ownership metadata. Exact + comparisons, safe output paths, deliberate updates, failure recovery, and + incompatible producer profiles have regression coverage. +- [The hand-tool](../src/documentation/scenarios/hand_tool.rs) and the entire + [navigation scenario](../src/documentation/scenarios/navigation.rs) author + their narratives beside real replay. Their old templates are removed. All + production input and behavioral assertions remain in place. The other 26 + features keep one legacy narrative source each. +- Reader/contributor candidate builds from one frozen execution. N3's legacy + 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 + 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. + +Small helpers address demonstrated repetition: `resource_code` registers and +places a captured resource, typed handles retain reuse, and the expression-list +form of `prose!` removes nested tuples when a paragraph has many text/binding +parts. The review follow-up added a named inline form described below, without +introducing template files or a procedural macro. N3's `Guide` wraps the existing capture calls so +registration cannot require a second capture or clock advance. + +Authoring is more unified but is not shorter in the two N3 migrations. Counts +include comments, blank lines, and all helpers in each scenario file: + +| Scope | Before | After | +| ----------------------------------------------------- | ---------------: | ----: | +| Hand tool: scenario plus separate narrative | 279 + 36 = 315 | 350 | +| Navigation: scenario plus separate narrative | 954 + 88 = 1,042 | 1,158 | +| Shared N3 Guide adapter, including focused regression | 0 | 242 | + +The adapter is additional shared code. Before the topology follow-up, the SDK had 2,706 lines +under `src/` and 2,304 under `tests/`, including comments, blank lines, and inline tests. Its README, +preview example, and package configuration are additional. These are not hidden +from the cost assessment. The example package +also reports complete narrative, helper, runner, and test counts. Preserving +exact legacy Markdown whitespace adds composition overhead; collecting observed +labels and returning typed captures also costs syntax. The benefit demonstrated +here is local narrative/evidence ownership, checked references, and one drift +workflow. Do not advertise a line-count reduction. Continue evaluating author +comprehension and repeated setup before adding more abstractions or migrating +all remaining guides. + +The minimum package toolchain is Rust 1.95. At the initial extraction, the maintained SDK and consumers +were copied outside N3 and passed 70 tests plus two README doctests on Rust 1.95.0 +and 1.98.1. Both real guide commands checked reader/contributor baselines, and +the preview example ran in each copy. A locally built `.crate` also passed Cargo +package verification and its unpacked tests. The copy's lockfile contains 39 +packages, with retained versions unchanged; no N3, egui, wgpu, or winit package is +in that graph. The SDK includes its MIT license; the README documents schema +version 1, compatibility limits, provenance, artifact producers, and recovery. +No package was published and no repository was created. + +The architecture, contributor instructions, and existing docs-driven development +skill now describe the actual boundary. The skill remains optional for SDK users. +`just doc-framework-test` is part of native `just verify` and the existing CI +checks. Native/web product routing remains unchanged; N3 depends on the framework +only on native targets. + +The initial native `just verify` passed with 954 N3 tests, 70 SDK/consumer tests, two README +doctests, tooling checks, and measurement checks. The existing manual CPU timing +probe remains ignored; no guide capture was skipped. The final framework gate +also builds API documentation with warnings denied. `just web-check` passed, +and the WebAssembly dependency graph excludes the SDK. + +The initial native guide and pinned Linux lavapipe receipt each passed all 28 features +and 136 generated artifacts. All 138 retained guide/receipt files remain +byte-identical to the pre-extraction inventory. Linux used the already-built +pinned image through the existing runner's read-only mounts and renderer policy +after Docker's base-image metadata lookup stalled. The complete Linux replay +preceded the isolated filesystem case-alias guard; the final source then compiled +and passed all 70 SDK/consumer tests and two doctests in that same image. That +guard changes candidate destinations, not replay or rendering. A missing-CLI +diagnostic differed under emulation; resolving the executable before launching +it restored the explicit failure contract without weakening checks or changing +guide output. The corrected consumer also passed its tests and both guide checks +on native Rust 1.95.0 and 1.98.1 outside N3. Reader/contributor HTML, captured +stills, and the existing hand-tool animation were reviewed locally. This does not +establish physical-device input recognition or independent developer acceptance. + +The remaining adoption gate is work with an independent project owner using +that project's own behavior and authoring needs. Longer-term schema/API +stability, Windows filesystem behavior, a public name and release, Git/diff +viewers, interactive comments, plugin infrastructure, and the eventual repository +move have not been established by this local implementation. The finished local +code is a reviewable starting point for those decisions. + +### Review follow-up: correctness and authoring + +Independent review reproduced five missing failure cases: typed Markdown could +expose authored HTML by changing inline-code context, JSON ordering could depend +on an adopter's `serde_json/preserve_order` feature, artifact filenames containing +entities could render broken links, unclosed Markdown containers could swallow +later blocks, and an ownership marker could declare nonexistent files without +failing checks. The fixes track generated HTML spans and validate the completed +composition, encode typed destinations, recursively canonicalize JSON objects, +and validate complete ownership with a narrowly scoped manifest-backed retirement +exception. Each case has behavioral regression coverage. + +Named inline prose keeps a sentence intact while retaining typed references: +`prose!("Hold {pan} over {viewport}.", pan = &pan, viewport = &viewport)?`. +It accepts named fields and escaped braces, without formatting modifiers. +Missing, duplicate, unused, or malformed bindings fail. Expressions run once; +substitution retains the original typed parts and their ownership/visibility +checks. This is an inline authoring convenience, not a second narrative source. +Ordinary paragraph, heading, and embed blocks remain the default for new pages; +exact `markdown_parts` composition supports the established N3 output contract. + +The hand-tool guide now composes its explanation outside its exhaustive replay +matrix. Its inputs, captures, and 17 assertion sites remain in their original +order. The complete file decreased from 356 to 350 lines, still above its original +315-line scenario-plus-template total. The improvement is continuous readable +prose and clearer separation of explanation from coverage, not a claim that the +framework solves thousand-line replay setup. + +`heading_with_id` and labeled `Block::link` references give important sections +deliberate identities while keeping positional IDs as the default. They do not +add a Git implementation or change N3's legacy fragment anchors. N3's imported +legacy claims remain numbered, and paragraph-attached contributor comments need +an explicit output-format decision before that adapter can support them. + +Artifact production is independent of the application, but the current exporter +targets Markdown. A public structured document view is deferred until a concrete +alternate publisher exercises audience filtering, inline references, frozen +observations, and resources together. The existing private document model is the +extension boundary; a plugin registry or a handful of unfiltered getters would +not establish that contract. + +Follow-up validation passed native `just verify`, including 954 N3 tests and +exact guide replay, 90 SDK/consumer tests, three README doctests, strict API docs, +tooling, and measurement checks. All 138 retained guide/receipt files remain +byte-identical. Fresh portable copies passed the 90 tests, three doctests, guide +checks and API docs on Rust 1.95.0 and 1.98.1; the rebuilt local archive also +passed independent unpacked tests. Feature-unified consumers produced identical +JSON, page, and manifest bytes with `preserve_order` enabled or disabled. The +cached pinned Linux image passed final-source N3 compilation, all 90 portable +tests, and three doctests without changing source or baseline bytes. That focused +Linux run did not repeat the earlier full GPU replay. + +### Project topology and configuration follow-up + +The maintained SDK now exposes `ExportLayout` and `RunnerConfig`. Page/resource +prefixes, exact page paths, audience baseline destinations, candidate audience +directories, and the base for relative filesystem paths belong to the adopter. +Direct `runner::run` calls support existing tests and build tools; the CLI parser +is optional. Existing defaults remain compatible. No repository discovery or +required source/fixture/site directory was added. + +Document finishing is independent of eventual export placement. Full export +validates the chosen topology, page-relative links, audience visibility, resource +use, and collisions before publication. Typed resource links rebase automatically; +explicit resource paths remain bundle-relative. The manifest and ownership +filenames, privacy rules, and strict comparison remain protocol contracts. + +The maintained `configured` example uses nested pages, moved resources, external +retained output, and custom audience directory names. A disposable adoption proof +also copied the SDK to `vendor/document-engine` with a separate consumer under +`tools/narrative-author`, no root Cargo workspace or Git repository, and an +external Cargo target. Rust 1.95 built that arrangement. Invocation from an +unrelated working directory and relocation of the entire source project produced +identical Reader and Contributor exports while preserving unrelated files. This +proves those integration shapes; it remains our test, not adoption by independent +maintainers. The current API and path semantics are documented in the SDK README. + +## Positioning and alternatives + +Executable documentation is an established family of ideas. The following +primary sources were checked on 2026-10-04: + +| Precedent | Existing overlap | Consequence for this proposal | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| [Cucumber](https://cucumber.io/docs/) | Executable plain-text scenarios, behavior verification, and documentation | Do not claim to invent intent expressed through executable examples | +| [Concordion](https://concordion.org/) | Natural-language living documentation, inline checks, screenshot and storyboard extensions | Documentation plus assertions and pictures is not itself a new category | +| [Insta](https://insta.rs/docs/quickstart/) | Rust snapshots, candidate output, and explicit review workflows | Reuse established review ideas; a snapshot does not establish narrative meaning | +| [mdBook](https://rust-lang.github.io/mdBook/cli/test.html) | Testing Rust examples within a book | Existing documentation publishers are potential consumers of generated Markdown | + +Our proposed contribution is a Rust-first packaging of the workflow N3 uses: +the reader's guide, selected behavioral promises, observed bindings, and retained +artifacts share one checked contract. Whether this combination is distinctive +and easier to adopt is a product hypothesis to test, not an established novelty +claim. Public material should spell out "docs-driven development" to avoid +confusion with domain-driven design. + +An extracted screenshot crate would miss most of that value. A skill-only +distribution would explain the method without enforcing drift checks. A new site +generator would add publishing constraints the adopter did not request. Extending +an existing snapshot library may reduce comparison/review work, but would still +need the narrative, identity, binding, and output-ownership layer; evaluate that +choice during the spike rather than committing to reimplement every utility. + +## Decisions to revisit after the first proof + +- Which inline syntax best preserves readable paragraphs, typed provenance, and + useful Rust diagnostics with the least bookkeeping. +- How much page composition the two consumers actually need before introducing + shared fragments, multiple scenarios per page, or custom formatters. +- Which publication/recovery strategy works for a dedicated output subtree on + supported hosts, and how an adopter explicitly reconciles retired files. +- Whether a separate Cargo subcommand saves enough setup to warrant packaging. +- Which real project will provide the independent adoption trial, and which + environment-dependent evidence it requires. +- Public name, minimum Rust version, distribution/license details, and schema + compatibility promises after the package boundary has been exercised. + +The maintained implementation now supports N3 and two non-GUI integration +shapes. Use the measured authoring costs and remaining adoption gate to guide +the next trial; keep speculative viewer and plugin features outside the core. diff --git a/rfcs/README.md b/rfcs/README.md index 6dfe206..de7ba56 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -34,7 +34,8 @@ 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. | +| 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. | diff --git a/src/documentation/artifacts.rs b/src/documentation/artifacts.rs index 081c8fa..0016fdc 100644 --- a/src/documentation/artifacts.rs +++ b/src/documentation/artifacts.rs @@ -1,5 +1,9 @@ //! Strict generated-tree ownership, bindings, validation and publication. use super::*; +use executable_docs::{ + Audience, + lifecycle::{self, Ownership}, +}; pub(super) fn render_template( template: &str, @@ -102,10 +106,22 @@ fn escape_html(text: &str) -> String { } pub(super) fn generate() -> Result { + Ok(generate_views(false)?.reader) +} + +struct Views { + reader: Artifacts, + contributor: Option, +} + +/// Both views are rendered from the same completed sessions and captured bytes. +/// Ordinary checks only render the published reader view. +fn generate_views(include_contributor: bool) -> Result { validate_feature_inventory()?; let mut capture = pollster::block_on(Capture::new(WIDTH, HEIGHT))?; let (media_width, media_height) = capture.framed_dimensions(); let mut result = Artifacts::new(); + let mut contributor = include_contributor.then(Artifacts::new); let mut manifest = format!( "n3 executable documentation v6\nrenderer: {}\nui-platform: macOS\napp-canvas: {WIDTH}x{HEIGHT}, 1 pixel/point, RGBA8Unorm\nmedia-canvas: {media_width}x{media_height}, lossless WebP\nframe-template: {}\nbaseline: Light theme; 240-point side panels\nclock: explicit scenario time; no wall clock\n", capture.renderer_profile, @@ -130,18 +146,35 @@ pub(super) fn generate() -> Result { if session.facts.is_empty() { return Err(format!("{} has no behavioral assertions", feature.slug)); } - let page = render_template( - feature.template, - &session.bindings, - &session.values, - &session.images, - &session.animations, - )?; + let page = |audience| match (feature.template, session.authored.as_ref()) { + (Some(template), None) => render_template( + template, + &session.bindings, + &session.values, + &session.images, + &session.animations, + ), + (None, Some(document)) if document.id() == feature.slug => Ok(format!( + "{GENERATED}{}", + document.render_fragment(audience)? + )), + _ => Err(format!( + "{} must own exactly one narrative source", + feature.slug + )), + }; insert( &mut result, format!("{}.md", feature.slug), - page.into_bytes(), + page(Audience::Reader)?.into_bytes(), )?; + if let Some(contributor) = contributor.as_mut() { + insert( + contributor, + format!("{}.md", feature.slug), + page(Audience::Contributor)?.into_bytes(), + )?; + } index.push_str(&format!("- [{}]({}.md)\n", feature.title, feature.slug)); manifest.push_str(&format!("\nfeature: {}\n", feature.slug)); for (id, path) in session.bindings { @@ -164,18 +197,34 @@ pub(super) fn generate() -> Result { }; manifest.push_str(&format!("{kind}: {path} ({} bytes)\n", bytes.len())); manifest.push_str(&format!("input: {}\n", session.image_inputs[&path])); + if let Some(contributor) = contributor.as_mut() { + insert(contributor, path.clone(), bytes.clone())?; + } insert(&mut result, path, bytes)?; } } + if let Some(contributor) = contributor.as_mut() { + insert(contributor, "README.md".into(), index.as_bytes().to_vec())?; + insert( + contributor, + "manifest.txt".into(), + manifest.as_bytes().to_vec(), + )?; + validate_links(contributor)?; + } insert(&mut result, "README.md".into(), index.into_bytes())?; insert(&mut result, "manifest.txt".into(), manifest.into_bytes())?; validate_links(&result)?; - Ok(result) + Ok(Views { + reader: result, + contributor, + }) } pub(super) fn validate_feature_inventory() -> Result<()> { let templates: BTreeSet<_> = FEATURES .iter() + .filter(|feature| feature.template.is_some()) .map(|f| format!("{}.md.in", f.slug)) .collect(); let scenarios: BTreeSet<_> = FEATURES @@ -206,42 +255,14 @@ pub(super) fn check_inventory( expected: &BTreeSet, current: &BTreeSet, ) -> Result<()> { - if expected == current { - Ok(()) - } else { - Err(format!( - "Every feature must own exactly one registered scenario and template. Missing: {:?}; unregistered: {:?}", - expected.difference(current).collect::>(), - current.difference(expected).collect::>() - )) - } + lifecycle::check_inventory(expected, current) } pub(super) fn validate_path(path: &str) -> Result<()> { - if path.is_empty() - || path.contains('\\') - || path - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..") - || Path::new(path) - .components() - .any(|part| !matches!(part, std::path::Component::Normal(_))) - { - return Err(format!("Invalid documentation artifact path: {path}")); - } - Ok(()) + lifecycle::validate_path(path) } pub(super) fn insert(artifacts: &mut Artifacts, path: String, bytes: Vec) -> Result<()> { - validate_path(&path)?; - match artifacts.entry(path) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(bytes); - } - std::collections::btree_map::Entry::Occupied(entry) => { - return Err(format!("Duplicate artifact owner: {}", entry.key())); - } - } - Ok(()) + lifecycle::insert(artifacts, path, bytes) } pub(super) fn validate_links(artifacts: &Artifacts) -> Result<()> { for (path, bytes) in artifacts.iter().filter(|(path, _)| path.ends_with(".md")) { @@ -267,48 +288,7 @@ pub(super) fn validate_links(artifacts: &Artifacts) -> Result<()> { Ok(()) } pub(super) fn read_tree(dir: &Path) -> Result { - fn visit(base: &Path, dir: &Path, files: &mut Artifacts) -> Result<()> { - if dir - .symlink_metadata() - .map_err(|e| e.to_string())? - .file_type() - .is_symlink() - { - return Err("Documentation tree must not contain symlinks".into()); - } - for item in std::fs::read_dir(dir).map_err(|e| e.to_string())? { - let item = item.map_err(|e| e.to_string())?; - let kind = item.file_type().map_err(|e| e.to_string())?; - if kind.is_symlink() { - return Err(format!( - "Documentation symlink is not supported: {}", - item.path().display() - )); - } - if kind.is_dir() { - visit(base, &item.path(), files)?; - } else if kind.is_file() { - let key = item - .path() - .strip_prefix(base) - .unwrap() - .to_str() - .ok_or("Non UTF-8 documentation path")? - .to_owned(); - insert( - files, - key, - std::fs::read(item.path()).map_err(|e| e.to_string())?, - )?; - } - } - Ok(()) - } - let mut files = Artifacts::new(); - if dir.exists() { - visit(dir, dir, &mut files)?; - } - Ok(files) + lifecycle::read_tree(dir) } pub(super) fn compare(expected: &Artifacts, current: &Artifacts) -> Result<()> { if let (Some(generated), Some(baseline)) = @@ -319,20 +299,11 @@ pub(super) fn compare(expected: &Artifacts, current: &Artifacts) -> Result<()> { "Documentation renderer mismatch: this run uses {generated}, but the saved guide uses {baseline}. Exact media checks require the same renderer. Run checks on the baseline renderer, or deliberately run just docs update on this host and review the new baseline. No images were ignored or updated." )); } - let paths: BTreeSet<_> = expected.keys().chain(current.keys()).collect(); - let drift: Vec<_> = paths - .into_iter() - .filter(|path| expected.get(*path) != current.get(*path)) - .cloned() - .collect(); - if drift.is_empty() { - Ok(()) - } else { - Err(format!( - "Documentation drift: {}. Run just docs update and review the generated changes (including images).", - drift.join(", ") - )) - } + lifecycle::compare(expected, current).map_err(|error| { + format!( + "{error}. Run just docs update and review the generated changes (including images)." + ) + }) } pub(super) fn renderer_profile(artifacts: &Artifacts) -> Option<&str> { @@ -342,21 +313,9 @@ pub(super) fn renderer_profile(artifacts: &Artifacts) -> Option<&str> { .find_map(|line| line.strip_prefix("renderer: ")) } pub(super) fn publish(dir: &Path, artifacts: &Artifacts) -> Result<()> { - let current = read_tree(dir)?; - let orphans: Vec<_> = current - .keys() - .filter(|key| !artifacts.contains_key(*key)) - .collect(); - if !orphans.is_empty() { - return Err(format!( - "Unowned documentation files: {orphans:?}; explicitly remove obsolete files before updating" - )); - } - for (path, bytes) in artifacts { - let destination = dir.join(path); - std::fs::create_dir_all(destination.parent().unwrap()).map_err(|e| e.to_string())?; - std::fs::write(destination, bytes).map_err(|e| e.to_string())?; - } + // Updating is an explicit renderer-baseline decision. Keep that N3 policy + // while sharing the same staged owned-tree transaction as other consumers. + lifecycle::update(dir, artifacts, Ownership::Dedicated, |_, _| Ok(()))?; compare(artifacts, &read_tree(dir)?) } @@ -364,6 +323,43 @@ pub fn run(mode: &str) -> Result<()> { execute(mode, &root().join("docs/guide"), generate) } +/// Build disposable reader and contributor views without accepting a baseline. +/// The destination is a fresh, dedicated generated tree; it cannot overwrite an +/// existing guide. Generation of both audiences completes before publication. +pub fn build(dir: &Path) -> Result<()> { + let dir = candidate_destination(dir, &root())?; + let views = lifecycle::prepare(|| generate_views(true))?; + let mut files = Artifacts::new(); + for (audience, artifacts) in [ + ("reader", views.reader), + ( + "contributor", + views.contributor.ok_or("Missing contributor view")?, + ), + ] { + for (path, bytes) in artifacts { + insert(&mut files, format!("{audience}/{path}"), bytes)?; + } + } + lifecycle::build(&dir, &files, Ownership::Dedicated)?; + println!( + "docs build: reader and contributor views at {}", + dir.display() + ); + Ok(()) +} + +fn candidate_destination(path: &Path, repository: &Path) -> Result { + let path = lifecycle::absolute(path)?; + for retained in ["docs/guide", "docs/baselines"] { + lifecycle::ensure_separate_paths(&path, &repository.join(retained))?; + } + if path.try_exists().map_err(|error| error.to_string())? { + return Err(format!("Build output already exists: {}", path.display())); + } + Ok(path) +} + pub(super) fn execute( mode: &str, dir: &Path, @@ -374,7 +370,7 @@ pub(super) fn execute( } // All UI actions, assertions, rendering, encoding, and template validation // complete before any documentation is written. A failing run cannot bless it. - let artifacts = generate()?; + let artifacts = lifecycle::prepare(generate)?; let canonical = read_tree(dir)?; if std::env::var("N3_DOCS_RENDERER").as_deref() == Ok("lavapipe") && renderer_profile(&artifacts) == Some(renderer_baseline::PROFILE) @@ -399,6 +395,64 @@ pub(super) fn execute( mod renderer_tests { use super::*; + #[test] + fn candidate_output_cannot_write_into_or_around_a_retained_baseline() { + let repository = root(); + for relative in [ + "docs", + "docs/guide", + "docs/guide/candidate", + "docs/baselines/candidate", + "docs/guide/./candidate", + "docs/guide/../candidate", + ".", + ] { + assert!( + candidate_destination(&repository.join(relative), &repository).is_err(), + "{relative}" + ); + } + 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"); + assert_eq!(candidate_destination(&fresh, &repository).unwrap(), fresh); + assert!(!fresh.exists()); + } + + #[cfg(unix)] + #[test] + fn candidate_preflight_rejects_case_aliases_of_retained_directories() { + use std::os::unix::fs::MetadataExt; + + let repository = root(); + for (retained, alias) in [ + ("docs/guide", "DOCS/GUIDE"), + ("docs/baselines", "DOCS/BASELINES"), + ] { + let baseline = repository.join(retained); + let alias = repository.join(alias); + let Ok(alias_metadata) = std::fs::metadata(&alias) else { + continue; + }; + let baseline_metadata = std::fs::metadata(&baseline).unwrap(); + if (alias_metadata.dev(), alias_metadata.ino()) + != (baseline_metadata.dev(), baseline_metadata.ino()) + { + continue; + } + let candidate = alias.join("case-alias-preflight-must-not-create"); + assert!(!candidate.exists()); + let before = read_tree(&baseline).unwrap(); + assert!( + candidate_destination(&candidate, &repository) + .unwrap_err() + .contains("overlap") + ); + assert!(!candidate.exists()); + assert_eq!(read_tree(&baseline).unwrap(), before); + } + } + fn baseline(profile: &str) -> Artifacts { Artifacts::from([ ( diff --git a/src/documentation/features.rs b/src/documentation/features.rs index 3a216fb..48ba3b0 100644 --- a/src/documentation/features.rs +++ b/src/documentation/features.rs @@ -1,179 +1,181 @@ -//! Every guide has one registered scenario and template. +//! Every guide has one scenario and exactly one narrative source. use super::*; pub(super) struct Feature { pub(super) slug: &'static str, pub(super) title: &'static str, - pub(super) template: &'static str, + pub(super) template: Option<&'static str>, pub(super) scenario: fn(&mut Session<'_>) -> Result<()>, } pub(super) const FEATURES: &[Feature] = &[ Feature { slug: "settings", title: "User settings", - template: include_str!("../../docs/templates/settings.md.in"), + template: Some(include_str!("../../docs/templates/settings.md.in")), scenario: settings::run, }, Feature { slug: "edit-selection", title: "Reading vertex selections", - template: include_str!("../../docs/templates/edit-selection.md.in"), + template: Some(include_str!("../../docs/templates/edit-selection.md.in")), scenario: edit_selection::run, }, Feature { slug: "numeric-transforms", title: "Numeric transforms", - template: include_str!("../../docs/templates/numeric-transforms.md.in"), + template: Some(include_str!( + "../../docs/templates/numeric-transforms.md.in" + )), scenario: numeric_transforms::run, }, Feature { slug: "snapping", title: "Snapping movement", - template: include_str!("../../docs/templates/snapping.md.in"), + template: Some(include_str!("../../docs/templates/snapping.md.in")), scenario: snapping::run, }, Feature { slug: "workspace", title: "Workspace layout and property editing", - template: include_str!("../../docs/templates/workspace.md.in"), + template: Some(include_str!("../../docs/templates/workspace.md.in")), scenario: workspace::run, }, Feature { slug: "terminal", title: "Terminal", - template: include_str!("../../docs/templates/terminal.md.in"), + template: Some(include_str!("../../docs/templates/terminal.md.in")), scenario: terminal::run, }, Feature { slug: "fps-meter", title: "FPS meter", - template: include_str!("../../docs/templates/fps-meter.md.in"), + template: Some(include_str!("../../docs/templates/fps-meter.md.in")), scenario: fps_meter::run, }, Feature { slug: "toasts", title: "Toasts and shortcut hints", - template: include_str!("../../docs/templates/toasts.md.in"), + template: Some(include_str!("../../docs/templates/toasts.md.in")), scenario: toasts::run, }, Feature { slug: "length-units", title: "Length units", - template: include_str!("../../docs/templates/length-units.md.in"), + template: Some(include_str!("../../docs/templates/length-units.md.in")), scenario: length_units::run, }, Feature { slug: "2d", title: "2D", - template: include_str!("../../docs/templates/2d.md.in"), + template: Some(include_str!("../../docs/templates/2d.md.in")), scenario: two_d::run, }, Feature { slug: "2d-ruler", title: "2D ruler", - template: include_str!("../../docs/templates/2d-ruler.md.in"), + template: Some(include_str!("../../docs/templates/2d-ruler.md.in")), scenario: ruler_2d::run, }, Feature { slug: "axis-locks", title: "Moving along axes", - template: include_str!("../../docs/templates/axis-locks.md.in"), + template: Some(include_str!("../../docs/templates/axis-locks.md.in")), scenario: axis_locks::run, }, Feature { slug: "editing", title: "Editing geometry", - template: include_str!("../../docs/templates/editing.md.in"), + template: Some(include_str!("../../docs/templates/editing.md.in")), scenario: editing::run, }, Feature { slug: "make-face", title: "Making a face", - template: include_str!("../../docs/templates/make-face.md.in"), + template: Some(include_str!("../../docs/templates/make-face.md.in")), scenario: make_face::run, }, Feature { slug: "object-feedback", title: "Finding and selecting objects", - template: include_str!("../../docs/templates/object-feedback.md.in"), + template: Some(include_str!("../../docs/templates/object-feedback.md.in")), scenario: object_feedback::run, }, Feature { slug: "selection-keys", title: "Selecting, duplicating and deleting with keys", - template: include_str!("../../docs/templates/selection-keys.md.in"), + template: Some(include_str!("../../docs/templates/selection-keys.md.in")), scenario: selection_keys::run, }, Feature { slug: "opening-models", title: "Opening models", - template: include_str!("../../docs/templates/opening-models.md.in"), + template: Some(include_str!("../../docs/templates/opening-models.md.in")), scenario: opening_models::run, }, Feature { slug: "scene-viewer", title: "Imported assets", - template: include_str!("../../docs/templates/scene-viewer.md.in"), + template: Some(include_str!("../../docs/templates/scene-viewer.md.in")), scenario: scene_viewer::run, }, Feature { slug: "animation", title: "Inspecting animation", - template: include_str!("../../docs/templates/animation.md.in"), + template: Some(include_str!("../../docs/templates/animation.md.in")), scenario: animation_inspection::run, }, Feature { slug: "navigation", title: "Navigation", - template: include_str!("../../docs/templates/navigation.md.in"), + template: None, scenario: navigation::run, }, Feature { slug: "local-view", title: "Local View", - template: include_str!("../../docs/templates/local-view.md.in"), + template: Some(include_str!("../../docs/templates/local-view.md.in")), scenario: local_view::run, }, Feature { slug: "view-keys", title: "Number keys and fitting the view", - template: include_str!("../../docs/templates/view-keys.md.in"), + template: Some(include_str!("../../docs/templates/view-keys.md.in")), scenario: view_keys::run, }, Feature { slug: "view-pie", title: "Choosing a view with the pie", - template: include_str!("../../docs/templates/view-pie.md.in"), + template: Some(include_str!("../../docs/templates/view-pie.md.in")), scenario: view_pie::run, }, Feature { slug: "xray", title: "Selecting through surfaces with X-ray", - template: include_str!("../../docs/templates/xray.md.in"), + template: Some(include_str!("../../docs/templates/xray.md.in")), scenario: xray::run, }, Feature { slug: "shading", title: "Viewport shading", - template: include_str!("../../docs/templates/shading.md.in"), + template: Some(include_str!("../../docs/templates/shading.md.in")), scenario: shading::run, }, Feature { slug: "hand-tool", title: "Panning with the hand tool", - template: include_str!("../../docs/templates/hand-tool.md.in"), + template: None, scenario: hand_tool::run, }, Feature { slug: "orbit-tool", title: "Temporary orbit tool", - template: include_str!("../../docs/templates/orbit-tool.md.in"), + template: Some(include_str!("../../docs/templates/orbit-tool.md.in")), scenario: orbit_tool::run, }, Feature { slug: "gizmo", title: "Axis gizmo", - template: include_str!("../../docs/templates/gizmo.md.in"), + template: Some(include_str!("../../docs/templates/gizmo.md.in")), scenario: gizmo::run, }, ]; diff --git a/src/documentation/guide.rs b/src/documentation/guide.rs new file mode 100644 index 0000000..165a618 --- /dev/null +++ b/src/documentation/guide.rs @@ -0,0 +1,242 @@ +//! N3's adapter for Rust-authored guides. Product input, witnesses, and captures +//! 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 std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Media { + Still, + Animation, +} + +pub(super) struct Guide { + pub(super) doc: Doc, + bindings: BTreeMap, + media: BTreeMap, +} + +impl Guide { + pub(super) fn new(id: &str, title: &str) -> Result { + Ok(Self { + doc: Doc::new(id, title)?, + bindings: BTreeMap::new(), + media: BTreeMap::new(), + }) + } + + fn binding(&mut self, id: String, value: BindingValue) -> Result { + if let Some(binding) = self.bindings.get(&id) { + return Ok(binding.clone()); + } + let binding = self.doc.binding(&id, value)?; + self.bindings.insert(id, binding.clone()); + Ok(binding) + } + + /// Labels must come from a control actually witnessed by this same replay. + pub(super) fn control(&mut self, session: &Session<'_>, control: Control) -> Result { + let label = session + .bindings + .get(&control) + .ok_or_else(|| format!("Authored guide control {} was not witnessed", control.id()))?; + self.binding( + format!("control-{}", control.id().replace('.', "-")), + BindingValue::Code(label.clone()), + ) + } + + /// Shortcut presentation is observed from the production catalog. It does + /// not deliver input or assert that an action was exercised by this call. + pub(super) fn shortcut(&mut self, id: &str) -> Result { + let keys = crate::input::bindings::binding(id)?.key_parts(); + self.binding( + format!("shortcut-{}", id.replace('.', "-")), + BindingValue::Keys(keys), + ) + } + + pub(super) fn modifier(&mut self, id: &str) -> Result { + let label = match id { + "shift" => "Shift", + "command" => "Command", + "control" => "Control", + "alt" => "Option", + _ => return Err(format!("Unknown documentation modifier: {id}")), + }; + self.binding( + format!("modifier-{id}"), + BindingValue::Keys(vec![label.into()]), + ) + } + + pub(super) fn capture_tutorial( + &mut self, + session: &mut Session<'_>, + name: &str, + ) -> Result { + session.capture_tutorial(name)?; + self.media(session, name, Media::Still) + } + + pub(super) fn capture_image( + &mut self, + session: &mut Session<'_>, + name: &str, + ) -> Result { + session.capture_image(name)?; + self.media(session, name, Media::Still) + } + + pub(super) fn capture_clip<'a>( + &mut self, + session: &mut Session<'a>, + name: &str, + spec: super::ClipSpec, + run: impl FnOnce(&mut Session<'a>) -> Result<()>, + ) -> Result { + session.capture_clip(name, spec, run)?; + self.media(session, name, Media::Animation) + } + + /// Register already captured bytes without another render or clock advance. + /// N3 additionally distinguishes still/animation uses of the same WebP MIME. + pub(super) fn media( + &mut self, + session: &Session<'_>, + name: &str, + kind: Media, + ) -> Result { + let path = format!("assets/{name}.webp"); + let bytes = session.images.get(&path).ok_or_else(|| { + format!("Authored guide media {name} was not captured by this scenario") + })?; + if session.animations.contains(&path) != (kind == Media::Animation) { + return Err(format!( + "Media {name} requires the correct still/animation reference" + )); + } + if let Some(resource) = self.media.get(&path) { + return Ok(resource.clone()); + } + let resource = self.doc.resource_at( + name, + Artifact::new( + bytes.clone(), + "image/webp", + "webp", + "n3-headless", + session.capture.renderer_profile, + )?, + &path, + )?; + self.media.insert(path, resource.clone()); + Ok(resource) + } + + 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)?; + if session.authored.is_some() { + return Err("A scenario must finish exactly one authored guide".into()); + } + // These entries exist only after Session::require succeeds. Import the + // already-executed legacy checks; never replace replay with assertions + // about a prepared fixture. The existing text manifest keeps its facts. + for (index, _) in session.facts.iter().enumerate() { + self.doc + .require(&format!("replay-assert-{index:04}"), true)?; + } + let document = self.doc.finish()?; + // N3's legacy manifest describes every Session capture. This bridge + // 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( + &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}"))?; + } + session.authored = Some(document); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::documentation::{Capture, GENERATED, HEIGHT, WIDTH, hand_tool, root}; + use executable_docs::Audience; + + #[test] + fn authored_hand_tool_reuses_frozen_evidence_and_keeps_notes_private() { + let mut capture = pollster::block_on(Capture::new(WIDTH, HEIGHT)).unwrap(); + let mut session = Session::new(&mut capture).unwrap(); + hand_tool::run(&mut session).unwrap(); + let clock = session.time; + let state = session.state.editor.document.clone(); + let captured = session.images.clone(); + let document = session.authored.as_ref().unwrap(); + let reader = document.render_fragment(Audience::Reader).unwrap(); + let contributor = document.render_fragment(Audience::Contributor).unwrap(); + let baseline = std::fs::read_to_string(root().join("docs/guide/hand-tool.md")).unwrap(); + assert_eq!(format!("{GENERATED}{reader}"), baseline); + let note = "The sampled replay verifies held input"; + assert!(!reader.contains(note)); + assert!(contributor.contains(note)); + assert_eq!(document.render_fragment(Audience::Reader).unwrap(), reader); + assert_eq!(session.time, clock, "rendering must not advance replay"); + assert_eq!(session.state.editor.document, state); + assert_eq!(session.images, captured); + + let mut probe = Guide::new("invalid-evidence", "Invalid evidence").unwrap(); + assert!(probe.control(&session, Control::PreferencesClose).is_err()); + assert!(probe.media(&session, "missing", Media::Still).is_err()); + assert!( + probe + .media(&session, "hand-tool-ready", Media::Animation) + .is_err() + ); + assert!( + probe + .media(&session, "hand-tool-pan-and-release", Media::Still) + .is_err() + ); + + session.authored = None; + let mut private = Guide::new("private-capture", "Private capture").unwrap(); + for name in [ + "hand-tool-ready", + "hand-tool-drag", + "hand-tool-pan-and-release", + ] { + let kind = if name == "hand-tool-pan-and-release" { + Media::Animation + } else { + Media::Still + }; + let resource = private.media(&session, name, kind).unwrap(); + if name == "hand-tool-ready" { + private.doc.note(&resource).unwrap(); + } else { + private.doc.embed(&resource, name).unwrap(); + } + } + assert!( + private + .finish(&mut session) + .unwrap_err() + .contains("every capture in the reader narrative") + ); + assert!( + session.authored.is_none(), + "reject private media before any export" + ); + } +} diff --git a/src/documentation/mod.rs b/src/documentation/mod.rs index 1c16d9e..829e020 100644 --- a/src/documentation/mod.rs +++ b/src/documentation/mod.rs @@ -12,10 +12,11 @@ pub(crate) mod animation; mod artifacts; pub(crate) mod capture; mod features; +mod guide; pub(crate) mod input; mod renderer_baseline; mod shortcut_replay; -pub use artifacts::run; +pub use artifacts::{build, run}; use artifacts::{insert, validate_path}; use features::FEATURES; @@ -139,7 +140,7 @@ pub type Result = std::result::Result; #[cfg(test)] #[path = "tests/doc_navigation_tests.rs"] mod doc_navigation_tests; -type Artifacts = BTreeMap>; +type Artifacts = executable_docs::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; @@ -219,6 +220,7 @@ pub struct Session<'a> { annotations: Vec, recorder: Option, animations: BTreeSet, + authored: Option, #[cfg(test)] extra_layout_pass: bool, } @@ -259,6 +261,7 @@ impl<'a> Session<'a> { annotations: Vec::new(), recorder: None, animations: BTreeSet::new(), + authored: None, #[cfg(test)] extra_layout_pass: false, }; diff --git a/src/documentation/renderer_baseline.rs b/src/documentation/renderer_baseline.rs index 1f15970..6cff8f7 100644 --- a/src/documentation/renderer_baseline.rs +++ b/src/documentation/renderer_baseline.rs @@ -478,6 +478,10 @@ mod tests { )); let canonical = guide("macos-metal"); let generated = guide(PROFILE); + // Resolve the test-owned temp root; production publication still rejects + // symlink components, including macOS's /var alias. + std::fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); artifacts::publish(&root.join("docs/guide"), &canonical).unwrap(); assert!( run("check", &root, &canonical, &generated) diff --git a/src/documentation/scenarios/hand_tool.rs b/src/documentation/scenarios/hand_tool.rs index 9cf6230..66b354f 100644 --- a/src/documentation/scenarios/hand_tool.rs +++ b/src/documentation/scenarios/hand_tool.rs @@ -1,5 +1,7 @@ -use super::{Result, Session, pointer}; +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 std::time::Duration; @@ -88,7 +90,7 @@ fn existing_gesture_keeps_priority( ) } -fn animated_hand_pan(s: &mut Session<'_>) -> Result<()> { +fn animated_hand_pan(s: &mut Session<'_>, guide: &mut Guide) -> Result { s.click(Control::ToolMove)?; s.click_path(&[Control::N3Menu, Control::ViewMenu, Control::Frame])?; prepare_selection(s, false)?; @@ -97,7 +99,7 @@ fn animated_hand_pan(s: &mut Session<'_>) -> Result<()> { let before = geometry(s); let start = s.state.viewport.center() + egui::vec2(-45.0, 25.0); s.frame(vec![egui::Event::PointerMoved(start)], Duration::ZERO)?; - s.capture_clip("hand-tool-pan-and-release", ClipSpec::default(), |s| { + let clip = guide.capture_clip(s, "hand-tool-pan-and-release", ClipSpec::default(), |s| { s.callout(Control::ToolMove, &format!("Hold {} to pan without changing tools.", s.shortcut_label("navigation.pan")?))?; s.wait(Duration::from_millis(500))?; s.shortcut_down("navigation.pan")?; @@ -136,13 +138,17 @@ fn animated_hand_pan(s: &mut Session<'_>) -> Result<()> { "The completed hand clip preserves geometry and selection and releases every gesture", )?; s.click_path(&[Control::N3Menu, Control::ViewMenu, Control::Frame])?; - Ok(()) + Ok(clip) } -pub fn run(s: &mut Session<'_>) -> Result<()> { - s.load_fixture("cube-quads.obj")?; - s.witness(Control::Viewport)?; - animated_hand_pan(s)?; +// Cover every tool/mode and interruption without making that test matrix dictate +// the order of the reader's explanation. Captures still use the original frames. +fn verify_modes_and_interruptions( + s: &mut Session<'_>, + guide: &mut Guide, +) -> Result<(Resource, Resource)> { + let mut ready = None; + let mut drag = None; let document = s.state.editor.document.clone(); let dirty = s.state.is_dirty(); for edit_mode in [false, true] { @@ -177,7 +183,7 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { "Holding the hand-tool key shows an open hand over the viewport without changing the persistent tool or camera", )?; if !edit_mode && tool == Tool::View { - s.capture_tutorial("hand-tool-ready")?; + ready = Some(guide.capture_tutorial(s, "hand-tool-ready")?); } s.frame(vec![pointer(start, true)], Duration::ZERO)?; let end = start + egui::vec2(38.0, -24.0); @@ -199,7 +205,7 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { "The hand-tool key with a primary drag shows a closed hand and pans with every modeling tool and both modes without rotating, zooming, editing, or changing selection", )?; if !edit_mode && tool == Tool::View { - s.capture_tutorial("hand-tool-drag")?; + drag = Some(guide.capture_tutorial(s, "hand-tool-drag")?); } let held_pose = s.state.camera.view_projection(s.state.aspect()); s.shortcut_up("navigation.pan")?; @@ -275,5 +281,70 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { "Returning focus does not resume the hand pan or turn the old pointer release into selection", )?; - Ok(()) + Ok(( + ready.ok_or("Object-mode hand-ready illustration was not captured")?, + drag.ok_or("Object-mode hand-drag illustration was not captured")?, + )) +} + +pub fn run(s: &mut Session<'_>) -> Result<()> { + s.load_fixture("cube-quads.obj")?; + s.witness(Control::Viewport)?; + let mut guide = Guide::new("hand-tool", "Panning with the hand tool")?; + let viewport = guide.control(s, Control::Viewport)?; + let pan = guide.shortcut("navigation.pan")?; + let orbit = guide.shortcut("navigation.orbit")?; + guide.doc.markdown_parts(prose!( + "# Panning with the hand tool + +Move the pointer over {viewport} and hold {pan}. +The open-hand cursor shows that you can pan without changing your selected +tool. This works in object and vertex edit modes with View, Move, Rotate, +or Scale selected. + +", + viewport = &viewport, + pan = &pan, + )?)?; + let clip = animated_hand_pan(s, &mut guide)?; + let (ready, drag) = verify_modes_and_interruptions(s, &mut guide)?; + guide.doc.markdown_parts(prose!( + "{ready} + +Keep {pan} held and click-drag with the left mouse button or +trackpad. The hand closes while you drag, and the view moves without rotating +or zooming. Your geometry and selection stay unchanged. + +{drag} + +This walkthrough shows {pan} held during the pan, then released while the pointer keeps moving. + +{clip} + +Release {pan} to return to your previous tool. Panning stops +immediately, even if the mouse button is still down; its later release does +not select anything. + +Press {pan} before starting the drag. A selection box or transform already in +progress keeps control if {pan} is pressed afterwards. Apply or cancel a +pending [Move session](axis-locks.md) before using the hand tool, even between +drag releases. Text fields and popups +keep their keyboard input, and leaving the application clears the held hand +tool. Two-finger scrolling, pinching, and twisting retain their usual behavior. + +Hold {orbit} instead to [orbit with the left button](orbit-tool.md). +{pan} takes precedence if both modifiers are held before a drag. Once a drag +starts, another modifier cannot change its action. + +See [navigation](navigation.md) or [editing geometry](editing.md). +[Back to guide](README.md). +", + ready = ready.image("hand tool ready"), + drag = drag.image("hand tool drag"), + clip = clip.image("hand tool pan and release"), + pan = &pan, + orbit = &orbit, + )?)?; + guide.doc.note_on(&clip, "The sampled replay verifies held input, an intermediate pan, release, and continued pointer motion. It exercises production routing; physical trackpad recognition is a separate native-device check.")?; + guide.finish(s) } diff --git a/src/documentation/scenarios/navigation.rs b/src/documentation/scenarios/navigation.rs index c445644..d343eb2 100644 --- a/src/documentation/scenarios/navigation.rs +++ b/src/documentation/scenarios/navigation.rs @@ -1,5 +1,6 @@ -use super::{Result, Session}; +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 std::time::Duration; @@ -107,6 +108,42 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { s.load_fixture("bracket.obj")?; s.settle()?; s.witness(Control::Viewport)?; + let mut guide = Guide::new("navigation", "Moving around")?; + let viewport = guide.control(s, Control::Viewport)?; + let pan = guide.shortcut("navigation.pan")?; + let orbit = guide.shortcut("navigation.orbit")?; + let shift = guide.modifier("shift")?; + let command = guide.modifier("command")?; + let mut mouse_orbit = None; + guide.doc.markdown_parts(prose![ + r#"# Moving around + +Move the pointer over "#, + &viewport, + r#": + +- Drag the **left mouse button** to draw a selection box. Selection updates when + you release; object bounds only need to intersect the box. With a + [transform axis lock](numeric-transforms.md), it previews the chosen Move, + Rotate, or Scale operation instead. +- Drag the **middle mouse button** to pan. +- Drag the **right mouse button** to orbit. +- Hold "#, + &pan, + r#" with a left-button drag to [pan](hand-tool.md), or hold + "#, + &orbit, + r#" with a left-button drag to [orbit](orbit-tool.md). + +These bindings work in object and vertex edit modes with every tool. Drag a +transform handle when you want to transform a selection. See +[selecting objects](object-feedback.md) and [editing](editing.md). +Apply or cancel a pending [transform session](numeric-transforms.md) before camera navigation +or background selection. + +"#, + ])?; + let framed = matrix(s); let start = s.state.viewport_ui_rect.left_bottom() + egui::vec2(55.0, -75.0); drag_mouse( @@ -239,7 +276,7 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { "A real held right-button drag orbits in object and vertex modes", )?; if !edit_mode { - s.capture_tutorial("navigation-mouse-orbit")?; + mouse_orbit = Some(guide.capture_tutorial(s, "navigation-mouse-orbit")?); } s.frame( vec![button(end, egui::PointerButton::Secondary, false)], @@ -290,7 +327,53 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { s.witness(Control::ViewportPreferences)?; s.hover(Control::ViewportFrame)?; s.frame(Vec::new(), crate::doc_input::CUE_LIFETIME)?; - s.capture_tutorial("navigation-context-menu")?; + let context_menu = guide.capture_tutorial(s, "navigation-context-menu")?; + let mouse_orbit = mouse_orbit.ok_or("Object-mode orbit illustration was not captured")?; + let viewport_frame = guide.control(s, Control::ViewportFrame)?; + let viewport_preferences = guide.control(s, Control::ViewportPreferences)?; + let threshold = guide.doc.expect_eq( + "mouse-drag-threshold", + DRAG_THRESHOLD.to_string(), + "4".to_owned(), + )?; + guide.doc.markdown_parts(prose![ + mouse_orbit.image("navigation mouse orbit"), + r#" + +Press and release the right button within **"#, + &threshold, + r#" +logical pixels** to open the viewport menu without moving the camera. It offers +"#, + &viewport_frame, + r#" and "#, + &viewport_preferences, + r#". Once a right +drag crosses that distance, releasing opens no menu, even if the pointer has +returned to its starting point. +Context menus use the same flat rows and hover feedback as dropdown menus. +Their shortcut hints appear beside the commands, including selection, framing, +and Preferences. Commands become available as the selection and editing mode change. + +"#, + context_menu.image("navigation context menu"), + r#" + +In **2D mode**, **two-finger scrolling pans** and keeps the 2D ruler visible in a +settled axis view. Hold "#, + &orbit, + r#" and left-drag to orbit into 3D. +On a trackpad, this is a click-drag while "#, + &orbit, + r#" is held. +"#, + &shift, + r#" + two-finger scrolling pans in either mode. + +"#, + ])?; + guide.doc.note_on(&context_menu, "The threshold value is checked against the production pointer policy. The scenario exercises motion at the threshold and a drag that crosses it, then returns before release.")?; + s.click(Control::ViewportFrame)?; s.require( s.trace.get(Control::ViewportFrame).is_err() && matrix(s).abs_diff_eq(framed, 1e-5), @@ -431,7 +514,50 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { panned(before, screen_geometry(s)), "The shared native scroll event pans the viewport and supplies its tutorial cue in the same replay step", )?; - s.capture_tutorial("navigation-aligned-trackpad")?; + let aligned_trackpad = guide.capture_tutorial(s, "navigation-aligned-trackpad")?; + let frame_key = guide.shortcut("view.frame")?; + let frame = guide.control(s, Control::Frame)?; + let view = guide.control(s, Control::ViewMenu)?; + guide.doc.markdown_parts(prose![ + aligned_trackpad.image("navigation aligned trackpad"), + r#" + +In **3D mode**, two-finger scrolling orbits and "#, + &shift, + r#" with scrolling +pans. This applies even if the camera is still perfectly aligned or +orthographic. Choose the mode with the [gizmo's 2D/3D tabs](gizmo.md); clicking +an axis or choosing an axis preset enters 2D. +The 3D tab restores your previous 3D orientation and perspective by default, +keeping the current pan and zoom. Manual orbit continues from the visible +angle; it does not perform that recall. The gizmo preferences configure the +3D tab's return behavior. + +Pinching and a mouse wheel zoom the viewport. In **2D mode**, zoom keeps the point beneath +the pointer in place. "#, + &command, + r#" + two-finger scrolling also zooms +around the pointer; ordinary two-finger scrolling pans. In **3D mode**, zoom +stays centered on the camera target. + +A two-finger twist is ignored in **2D**, so a +twist during panning or pinching cannot switch the view to 3D. In **3D**, twisting +rotates the view. Panning +and zooming preserve the viewing direction. These trackpad bindings work in +object and vertex edit modes. Twisting is available only when the host exposes +rotation gestures. + +1. Press "#, + &frame_key, + r#" or choose "#, + &frame, + r#" in "#, + &view, + r#" to fit and recenter + the model and restore its default viewing angle. + +"#, + ])?; } // Replay rotation deltas between native scroll phases. This checks // the adapter policy, not macOS recognition of physical gestures. @@ -653,7 +779,33 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { !open && s.shortcut_is_down("view.frame")? && matrix(s).abs_diff_eq(framed, 1e-5), "Pressing the Frame all shortcut invokes Frame and remains observable while held", )?; - s.capture_tutorial("navigation-frame-key")?; + let frame_image = guide.capture_tutorial(s, "navigation-frame-key")?; + let view = guide.control(s, Control::ViewMenu)?; + let view_top = guide.control(s, Control::ViewTop)?; + let view_front = guide.control(s, Control::ViewFront)?; + let view_right = guide.control(s, Control::ViewRight)?; + let view_perspective = guide.control(s, Control::ViewPerspective)?; + guide.doc.markdown_parts(prose![ + frame_image.image("navigation frame key"), + r#" + +2. Open "#, + &view, + r#" and point at "#, + &view_top, + r#" to find a preset. + Choose it, "#, + &view_front, + r#", "#, + &view_right, + r#", or + "#, + &view_perspective, + r#" to change the view with the current animation settings. + +"#, + ])?; + s.require( s.shortcut_is_down("view.frame")? && matrix(s).abs_diff_eq(framed, 1e-5), "The keyboard tutorial capture preserves the held Frame all shortcut and the framed view", @@ -696,7 +848,52 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { && matrix(s).abs_diff_eq(before_menu, 1e-5), "Hovering Top keeps the real View popup open without changing the camera", )?; - s.capture_tutorial("navigation-view-menu")?; + let view_menu = guide.capture_tutorial(s, "navigation-view-menu")?; + let projection = guide.control(s, Control::Projection)?; + let projection_key = guide.shortcut("view.projection")?; + let edges = guide.control(s, Control::Edges)?; + let grid = guide.control(s, Control::Grid)?; + let preferences = guide.control(s, Control::Preferences)?; + let precise_scroll = guide.control(s, Control::PreciseScroll)?; + guide.doc.markdown_parts(prose![ + view_menu.image("navigation view menu"), + r#" + +For the separate top-row and numpad bindings, orbit steps, and fitting a +selection, see [number keys and fitting the view](view-keys.md). + +Click the "#, + &projection, + r#" cube button beside the gizmo's 2D/3D tabs, or +press "#, + &projection_key, + r#" to switch between perspective and orthographic +projection. The icon shows the current projection; see the [gizmo guide](gizmo.md#projection) +for both states. "#, + &edges, + r#" and "#, + &grid, + r#" checkboxes control the +polygon boundaries and ground grid. + +Open "#, + &preferences, + r#" to find "#, + &precise_scroll, + r#". +Enable it to make unmodified precise scrolling zoom in 3D mode. In 2D, +scrolling still pans, with or without "#, + &shift, + r#"; hold +"#, + &command, + r#" to zoom. + +3. Use the front view to inspect the model straight on. + +"#, + ])?; + s.click(Control::ViewPerspective)?; s.frame( Vec::new(), @@ -712,9 +909,16 @@ pub fn run(s: &mut Session<'_>) -> Result<()> { complete_view(s, Control::ViewFront, Some(Vec3::Z))?; s.settle()?; s.witness(Control::Projection)?; - s.capture_image("navigation")?; + let front_image = guide.capture_image(s, "navigation")?; + guide.doc.markdown_parts(prose![ + front_image.image("navigation"), + r#" +"#, + ])?; + verify_planar_zoom(s)?; - verify_browser_navigation(s) + verify_browser_navigation(s)?; + guide.finish(s) } /// Exercise pointer anchoring through the native/replay funnel without adding diff --git a/src/documentation/tests/harness.rs b/src/documentation/tests/harness.rs index c7094dd..d0058f8 100644 --- a/src/documentation/tests/harness.rs +++ b/src/documentation/tests/harness.rs @@ -650,6 +650,9 @@ fn failed_scenario_cannot_update_any_existing_artifact() { .as_nanos() )); std::fs::create_dir(&dir).unwrap(); + // macOS's system temp path traverses /var -> /private/var. Resolve this + // test-owned directory before exercising the strict no-symlink lifecycle. + let dir = dir.canonicalize().unwrap(); std::fs::write(dir.join("gizmo.md"), "previous verified guide").unwrap(); let before = read_tree(&dir).unwrap(); let result = execute("update", &dir, || { diff --git a/src/lib.rs b/src/lib.rs index f723893..68c9541 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,12 +67,24 @@ pub fn run_native() -> Result<(), Box> { editor::EditorAccess::ReadWrite }; if initial.as_deref() == Some(std::ffi::OsStr::new("--docs")) { + const USAGE: &str = "Usage: n3 --docs update|check | --docs build --out PATH"; let mode = args .next() .and_then(|mode| mode.into_string().ok()) - .ok_or("Usage: n3 --docs update|check")?; + .ok_or(USAGE)?; + if mode == "build" { + if args.next().as_deref() != Some(std::ffi::OsStr::new("--out")) { + return Err(USAGE.into()); + } + let output = args.next().map(PathBuf::from).ok_or(USAGE)?; + if args.next().is_some() { + return Err(USAGE.into()); + } + doc_harness::build(&output).map_err(std::io::Error::other)?; + return Ok(()); + } if !matches!(mode.as_str(), "update" | "check") || args.next().is_some() { - return Err("Usage: n3 --docs update|check".into()); + return Err(USAGE.into()); } doc_harness::run(&mode).map_err(std::io::Error::other)?; return Ok(()); diff --git a/tools/ci_runner.py b/tools/ci_runner.py index c9f596c..7dcba90 100644 --- a/tools/ci_runner.py +++ b/tools/ci_runner.py @@ -43,8 +43,8 @@ """ CI_CHECKS_PREFIX = """\ python3 tools/format_docs.py --check -cargo fmt --check -cargo clippy --locked --all-targets -- -D warnings +cargo fmt --all --check +cargo clippy --locked --workspace --all-targets -- -D warnings python3 -m unittest discover -s tools/tests -p 'test_*.py' node --test tools/tests/web_wrapper.test.mjs python3 -m unittest discover -s tools/benchmark/tests -t . -p 'test_*.py' @@ -54,9 +54,14 @@ 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 """ -CI_SCRIPT = CI_CHECKS_PREFIX + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" +DOC_FRAMEWORK_CHECKS = """\ +cargo test --locked -p executable-docs -p doc-example-config --all-targets +cargo test --locked -p executable-docs --doc +RUSTDOCFLAGS='-D warnings' cargo doc --locked -p executable-docs --no-deps +""" +CI_SCRIPT = CI_CHECKS_PREFIX + DOC_FRAMEWORK_CHECKS + BENCHMARK_RUST_CHECKS + "exec cargo test --locked\n" CI_PARTITION_SCRIPTS = { - "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + BENCHMARK_RUST_CHECKS + "checks": CI_CHECKS_PREFIX + "python3 tools/ci_test_inventory.py\n" + DOC_FRAMEWORK_CHECKS + 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 977d537..67f546c 100644 --- a/tools/tests/test_ci_runner.py +++ b/tools/tests/test_ci_runner.py @@ -101,12 +101,15 @@ def test_ci_runs_full_checks_without_recursing_through_just_or_runner(self): arguments = self.command(["ci"]) script = arguments[arguments.index("-c") + 1] for command in ( - "python3 tools/format_docs.py --check", "cargo fmt --check", - "cargo clippy --locked --all-targets -- -D warnings", + "python3 tools/format_docs.py --check", "cargo fmt --all --check", + "cargo clippy --locked --workspace --all-targets -- -D warnings", "python3 -m unittest discover -s tools/tests -p 'test_*.py'", "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", + "cargo test --locked -p executable-docs -p doc-example-config --all-targets", + "cargo test --locked -p executable-docs --doc", + "RUSTDOCFLAGS='-D warnings' cargo doc --locked -p executable-docs --no-deps", "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", @@ -133,6 +136,7 @@ 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) @@ -156,7 +160,36 @@ def test_partition_inventory_failure_prevents_test_execution_in_both_shell_scrip result = subprocess.run(["/bin/sh", "-eu", "-c", runner.CI_PARTITION_SCRIPTS[mode]], cwd=self.root, env=environment, check=False) self.assertEqual(result.returncode, 7) calls = record.read_text().splitlines() if record.exists() else [] - self.assertEqual(calls, ["fmt --check", "clippy --locked --all-targets -- -D warnings"] if mode == "checks" 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\n" + "printf '%s\\n' \"$*\" >> \"$N3_TEST_RECORD\"\n" + "if [ \"$*\" = 'test --locked -p executable-docs -p doc-example-config --all-targets' ]; then\n" + " exit 19\n" + "fi\n" + ) + cargo.chmod(0o755) + for name in ("python3", "node"): + command = commands / name + command.write_text("#!/bin/sh\nexit 0\n") + command.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", + "test --locked -p executable-docs -p doc-example-config --all-targets", + ]) def test_environment_receipt_reports_only_architecture_libc_and_cpu_features(self): cpuinfo = """processor : 0