diff --git a/.gitea/workflows/checker-check.yml b/.gitea/workflows/checker-check.yml new file mode 100644 index 0000000..a6ce325 --- /dev/null +++ b/.gitea/workflows/checker-check.yml @@ -0,0 +1,53 @@ +name: checker check + +# Builds and lints the Rust implementation crates under crates/ (the rule-based +# checker that "stands in Lean's position" at product runtime). +# +# Like spec-check, this is an INTERNAL gate on the implementation's own health +# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a +# spec-to-implementation conformance gate — implementations align to the Lean +# contract by human review, not by CI. See the repo README. + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + checker-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Rust toolchain (stable + clippy + rustfmt) + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + # Fonts for typst compilation (WU-4 onward): the engineering files are + # CJK-heavy, so the compile-check / build tests need a CJK font present. + # Harmless before those crates exist. + - name: Install CJK fonts + run: | + sudo apt-get update + sudo apt-get install -y fonts-noto-cjk + + - name: Format check + run: cargo fmt --all --check + + - name: Clippy (deny warnings) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --all-features --workspace diff --git a/.gitea/workflows/spec-check.yml b/.gitea/workflows/spec-check.yml new file mode 100644 index 0000000..8e862dc --- /dev/null +++ b/.gitea/workflows/spec-check.yml @@ -0,0 +1,20 @@ +name: spec check + +# Builds the Lean semantic master spec under spec/. +# This is an INTERNAL well-formedness gate (does the contract type-check?), +# NOT a spec-to-implementation conformance gate — implementations align to the +# contract by human review, not by CI. See repo README. + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + spec-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: leanprover/lean-action@v1 + with: + lake-package-directory: spec diff --git a/.gitignore b/.gitignore index 3bdd52e..a28e9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ -node_modules/ -dist/ +# Lean / Lake build artifacts (spec/ has its own .gitignore too) +.lake/ +**/.lake/ + +# Rust / Cargo build artifacts (repo-wide cargo workspace at root) +/target +**/*.pdf +.cph/ + +# Test-only @local package resolution convenience (self-referential symlink); +# regenerable, not for VCS. The embedded engine mounts cph-render directly. +render/vendor/local-packages/ + +# OS / editor .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a076d38 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md —— agent 操作手册(全 repo) + +本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 + +## 这个 repo 是什么 + +- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 +- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 + +## 纪律 + +1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 + +2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 + +3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。 + +4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 + +5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5ef80d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3291 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "biblatex" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561" +dependencies = [ + "paste", + "roman-numerals-rs", + "strum", + "unic-langid", + "unicode-normalization", + "unscanny", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chinese-number" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e964125508474a83c95eb935697abbeb446ff4e9d62c71ce880e3986d1c606b" +dependencies = [ + "chinese-variant", + "enum-ordinalize", + "num-bigint", + "num-traits", +] + +[[package]] +name = "chinese-variant" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b52a9840ffff5d4d0058ae529fa066a75e794e3125546acfc61c23ad755e49" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "citationberg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756ff1e3d43a9ecc8183932fb4d9fd3971236f3ce4acb62fe51d1cd43297547d" +dependencies = [ + "quick-xml 0.38.4", + "serde", + "serde_path_to_error", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "codex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0732ab1a27b4ea05e6f9f60a5122c9924dd5123defde0d8e907f58cf643d40e6" +dependencies = [ + "chinese-number", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "comemo" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c963350b2b08aa4b725d7802593245380ab53dacfedcaa971385fc33306c0d4" +dependencies = [ + "comemo-macros", + "parking_lot", + "rustc-hash", + "siphasher", + "slab", +] + +[[package]] +name = "comemo-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cph-check" +version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "cph-schema", + "cph-typst", +] + +[[package]] +name = "cph-cli" +version = "0.1.0" +dependencies = [ + "clap", + "cph-check", + "cph-diag", + "cph-typst", +] + +[[package]] +name = "cph-diag" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "cph-model" +version = "0.1.0" +dependencies = [ + "cph-diag", + "serde", + "toml", +] + +[[package]] +name = "cph-schema" +version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "serde_json", + "toml", +] + +[[package]] +name = "cph-typst" +version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "cph-schema", + "dirs", + "include_dir", + "tempfile", + "toml", + "typst", + "typst-kit", + "typst-layout", + "typst-library", + "typst-pdf", + "typst-syntax", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" +dependencies = [ + "serde", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa", + "smallvec", + "vello_common 0.0.9", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hayagriva" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b6c185c5c72546d50b25b70187f01ab9a1a2fa21c4db8691e2426fa5fce805c" +dependencies = [ + "biblatex", + "ciborium", + "citationberg", + "icu_collator", + "icu_locale", + "indexmap", + "paste", + "roman-numerals-rs", + "serde", + "serde_yaml", + "thiserror", + "unic-langid", + "unicode-segmentation", + "unscanny", + "url", +] + +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-svg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7434c89e920d84e077214a29e69b462a6518754610f732f1124af3948410cb8c" +dependencies = [ + "base64", + "hayro-interpret", + "image", + "kurbo", + "siphasher", + "xmlwriter", +] + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "hayro-write" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +dependencies = [ + "flate2", + "hayro-syntax", + "pdf-writer", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hypher" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef68590049bab63a464eee1a1158ac04c6f6613a546d8d90f78636b8b94f171" + +[[package]] +name = "icu_collator" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b521b92a2666061ddda902769d8a4cf730b5c9529a845cc1b69770b12a6c9a71" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "serde", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_provider_blob" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99a43c4436b6c9a37c27a822ff05238bbad134bcdf6176732208acfae46a3f" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "core_maths", + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "serde", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "rayon", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "krilla" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +dependencies = [ + "base64", + "bumpalo", + "comemo", + "flate2", + "float-cmp", + "gif", + "hayro-write", + "image-webp", + "imagesize", + "indexmap", + "pdf-writer", + "png", + "rayon", + "rustc-hash", + "rustybuzz", + "siphasher", + "skrifa", + "smallvec", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke", + "zune-jpeg", +] + +[[package]] +name = "krilla-svg" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "resvg", + "skrifa", + "smallvec", + "tiny-skia", + "usvg", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lipsum" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064" +dependencies = [ + "rand", + "rand_chacha", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags", + "itoa", + "memchr", + "ryu", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b05a62c3762cb26cb62665b915fea652def8a9f609b0b289b7f782a7394307b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roman-numerals-rs" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo", + "rustc-hash", + "skrifa", + "write-fonts", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[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", + "windows-sys", +] + +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "two-face" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typst" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8bf2a5a9d58cc542764a88dd43c8a679b683db4ae23e36267219339ab36b01" +dependencies = [ + "arrayvec", + "comemo", + "ecow", + "rustc-hash", + "typst-eval", + "typst-html", + "typst-layout", + "typst-library", + "typst-macros", + "typst-realize", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-assets" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9b236d339dce0ec443dc44afe4ba4c6d76b8917bd4b1ae80158d2111f6637b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "typst-eval" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d064876305073ab75d5af039ae5e8dcb32c4cac4aa9f43d0f5aae347baef7c3" +dependencies = [ + "comemo", + "ecow", + "indexmap", + "rustc-hash", + "stacker", + "toml", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-segmentation", +] + +[[package]] +name = "typst-html" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d442f92bae44087735efc8b83508b259c573bbccf027e098ac68c8f4ca879f76" +dependencies = [ + "az", + "bumpalo", + "comemo", + "ecow", + "palette", + "rustc-hash", + "time", + "typst-assets", + "typst-library", + "typst-macros", + "typst-svg", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-math-class", +] + +[[package]] +name = "typst-kit" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a24650fc436d32deb55b49fd63f7bcad3a864c96f52587b9934c85e0092d6f" +dependencies = [ + "dirs", + "ecow", + "fontdb", + "once_cell", + "parking_lot", + "rustc-hash", + "serde", + "serde_json", + "typst-assets", + "typst-library", + "typst-syntax", + "typst-timing", + "typst-utils", + "url", +] + +[[package]] +name = "typst-layout" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5af3949efc6d02e3d55e79430c9096415fcf68166414c3a0e5fed1c3325d5f" +dependencies = [ + "az", + "bumpalo", + "codex", + "comemo", + "ecow", + "either", + "hypher", + "icu_properties", + "icu_provider", + "icu_provider_blob", + "icu_segmenter", + "kurbo", + "libm", + "memchr", + "rustc-hash", + "rustybuzz", + "smallvec", + "ttf-parser", + "typst-assets", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-bidi", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "typst-library" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9550a33d0f4e1965fd7d69abcf10135e965a4ad3a3aee24573b52dda9234e491" +dependencies = [ + "arrayvec", + "az", + "bitflags", + "bumpalo", + "ciborium", + "codex", + "comemo", + "csv", + "ecow", + "either", + "flate2", + "fontdb", + "glidesort", + "hayagriva", + "hayro-syntax", + "icu_properties", + "image", + "indexmap", + "kamadak-exif", + "kurbo", + "libm", + "lipsum", + "memchr", + "moxcms", + "palette", + "percent-encoding", + "phf", + "png", + "rayon", + "regex", + "regex-syntax", + "roxmltree 0.21.1", + "rust_decimal", + "rustc-hash", + "rustybuzz", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "smallvec", + "syntect", + "time", + "toml", + "ttf-parser", + "two-face", + "typed-arena", + "typst-assets", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-math-class", + "unicode-normalization", + "unicode-segmentation", + "unscanny", + "usvg", + "utf8_iter", + "wasmi", + "xmlwriter", +] + +[[package]] +name = "typst-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500e2873a544ca161f98183eea7ddb00030d16f4585b5ffa9e9c8e24f53148e1" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typst-pdf" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7b33bcabc3357480768f6c78dda99a838d621c71b4738b25e09ac30ac063c9" +dependencies = [ + "az", + "bytemuck", + "codex", + "comemo", + "ecow", + "flate2", + "image", + "indexmap", + "infer", + "krilla", + "krilla-svg", + "rustc-hash", + "serde", + "smallvec", + "typst-assets", + "typst-layout", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-realize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917e09614771258b13409b9a8d4f3915b2a2f7f28ca987701695f82fc6a7c0b9" +dependencies = [ + "arrayvec", + "bumpalo", + "comemo", + "ecow", + "regex", + "typst-html", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-svg" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "878b6e1293c2bea77a8be50670d1cbca4e676af96480313a105cba539da51d1c" +dependencies = [ + "base64", + "comemo", + "ecow", + "flate2", + "hayro", + "hayro-svg", + "image", + "indexmap", + "itoa", + "rustc-hash", + "ryu", + "ttf-parser", + "typst-assets", + "typst-layout", + "typst-library", + "typst-macros", + "typst-timing", + "typst-utils", + "xmlwriter", +] + +[[package]] +name = "typst-syntax" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22bf7f87450e5841debd4986b0f912b0f7a2251e600c08aca406305ff52c67b4" +dependencies = [ + "ecow", + "rustc-hash", + "serde", + "toml", + "typst-timing", + "typst-utils", + "unicode-ident", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", + "unscanny", +] + +[[package]] +name = "typst-timing" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc0c78ece2ade6ef73d00a13f9c474e02efc3bcfdb770e16d7c4d24e7492773" +dependencies = [ + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "typst-utils" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40215eb2541102ecfb4cf3bef29da53033a82e22dbc2308c43bdb855701bf8d2" +dependencies = [ + "libm", + "once_cell", + "portable-atomic", + "rayon", + "rustc-hash", + "semver", + "siphasher", + "smallvec", + "thin-vec", + "unicode-math-class", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-math-class" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d246cf599d5fae3c8d56e04b20eb519adb89a8af8d0b0fbcded369aa3647d65" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "png", + "vello_common 0.0.8", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasmi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884" + +[[package]] +name = "wasmi_core" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types", + "indexmap", + "kurbo", + "log", + "read-fonts", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "xmp-writer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "litemap", + "serde_core", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ca2868d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" +members = [ + "crates/cph-diag", + "crates/cph-model", + "crates/cph-schema", + "crates/cph-typst", + "crates/cph-check", + "crates/cph-cli", +] + +# Shared metadata for all workspace crates. Individual crates inherit these via +# `version.workspace = true` / `edition.workspace = true`. +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" +cph-diag = { path = "crates/cph-diag" } +cph-model = { path = "crates/cph-model" } diff --git a/README.md b/README.md index ba5a6d2..62285d9 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,50 @@ -# Curriculum Project Hub +# curriculum-project-hub -教研项目工作台的架构仓库。 +教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 -当前阶段先用 LikeC4 把产品边界、系统职责、核心领域模型和关键运行时流程敲定,再进入实现。 +这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。** -## Architecture +## 仓库布局 -LikeC4 model: - -- [docs/architecture/likec4/specification.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/specification.c4) -- [docs/architecture/likec4/model.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/model.c4) -- [docs/architecture/likec4/views.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/views.c4) - -Commands: - -```sh -npm install -npm run dev -npm run validate -npm run build +``` +README.md ← 本文件:总览 + 宪法(下面 5 条) +CLAUDE.md ← 全局 agent 操作手册(管整个 repo) +docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用) +spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md +Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) +crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md + cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) + cph-check / cph-cli ← checker 本体 + `cph` 命令行 +render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) +examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 +(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建 ``` -Key views to review first: +`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。 +实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 +未来部件(如 exporter)复用,而非各自重造。 -- `index`: system context -- `containers`: target containers and external systems -- `permissions`: Feishu Docs-like permission model -- `domain_model`: Project / ProjectGroupChat / AgentSession / AgentRun / ProjectAgentLock -- `run_lifecycle`: `@Claude` creates a run and holds a project lock -- `context_access`: Claude reads Feishu history on demand through MCP +## 宪法 -## Decisions +这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。 -Architecture decisions live in [docs/architecture/adr](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/adr). +1. **角色 —— Lean 是研发侧的上游参照。** + `spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。 + +2. **对齐机制 —— Lean 只做上游参照。** + 不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。 + (CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。) + +3. **资产性 —— 由 review 纪律承载,无机器兜底。** + 这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。 + +4. **形态 —— 它是人机共识的契约。** + 契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。 + +5. **深度判据 —— 只收录分歧点。** + 一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。 + 深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。 + +## CI + +`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。 diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..11eb86a --- /dev/null +++ b/crates/README.md @@ -0,0 +1,34 @@ +# crates/ + +These crates implement the rule-based lesson checker that aligns to the +semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005) +laid out per ADR-0008 (declarative `manifest.toml` + per-element +`element.toml`), validates structure and content, and emits diagnostics. +`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), +and `cph-typst` (the typst `World` / compile / span-mapping layer) are +deliberately reusable by future components such as an `exporter`, which is why +they live in this repo-wide `crates/` directory rather than under any single +component; `cph-schema` (kind JSON Schemas + validation), `cph-check` +(orchestration + render-coverage) and `cph-cli` (the `cph` command-line +entrypoint) are the checker proper. + +## Crate map + +| crate | owner | role | +|---------------|-------|------| +| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | +| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | +| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | +| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | +| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | +| `cph-cli` | WU-5 | the `cph` command-line entrypoint | + +## Build + +```sh +# from the repository root (the cargo workspace root) +cargo build # all crates +cargo test # cph-diag + cph-model unit/integration tests +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` diff --git a/crates/cph-check/Cargo.toml b/crates/cph-check/Cargo.toml new file mode 100644 index 0000000..5408e07 --- /dev/null +++ b/crates/cph-check/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cph-check" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +cph-schema = { path = "../cph-schema" } +cph-typst = { path = "../cph-typst" } diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs new file mode 100644 index 0000000..d547846 --- /dev/null +++ b/crates/cph-check/src/lib.rs @@ -0,0 +1,912 @@ +//! `cph-check` — check-pipeline orchestration. +//! +//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema` +//! (validate), and `cph-typst` (compile), runs the render-coverage rule +//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics. +//! +//! The orchestrator owns *sequencing and gating*, not the individual checks: +//! each phase is implemented by an upstream crate, and `cph-check` decides the +//! order, what gates what, and how the results are merged / deduped. See +//! [`check`] for the full pipeline and [`build`] for the export path. + +use std::path::Path; + +use cph_diag::{DiagCode, Diagnostic, Severity}; +use cph_typst::Engine; + +/// The default render target when a lesson declares no `[targets.*]` at all. +const DEFAULT_TARGET: &str = "student"; + +/// Severity of the render-coverage ("element ignored under a target") diagnostic. +/// +/// **PINNED to `warning` by the contract.** Mirrors the Lean master's +/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning` +/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a +/// `(kind, target)` pair has no render rule the checker reports that the element +/// is ignored under that target and **does not block the export**. Naming the +/// severity as a const makes "it is a warning, not an error" a greppable, +/// alignable fact rather than an inline literal. +const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; + +/// The result of running [`check`] (or the check phases of [`build`]). +#[derive(Debug, Clone, PartialEq)] +pub struct CheckReport { + /// Every diagnostic collected across all phases, deduplicated. + pub diagnostics: Vec, + /// `false` if `cph_model::load` returned `None` (a hard manifest failure + /// that prevents any further phase from running). + pub lesson_loaded: bool, +} + +impl CheckReport { + /// How many collected diagnostics are `Error`-severity. + pub fn error_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .count() + } + + /// How many collected diagnostics are `Warning`-severity. + pub fn warning_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|d| d.severity == Severity::Warning) + .count() + } + + /// Whether any collected diagnostic is `Error`-severity. + /// + /// **Legality decision (spec alignment).** `!has_errors()` is the + /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`): + /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic + /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI + /// gate enforcing this alignment (repo constitution); it is kept greppable + /// here so a reviewer can tie the orchestrator's gate to the Lean master. + pub fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|d| d.severity == Severity::Error) + } +} + +/// Run the full check pipeline against the engineering file at `root`. +/// +/// Phases, in order, with gating: +/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard +/// failure (`None`) the report is returned immediately with +/// `lesson_loaded == false`; nothing downstream can run. +/// - **(b) structural** — for each part, check `part.kind` is in +/// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error. +/// (cph-model already checked path existence, `..` traversal, and +/// part/element kind *mismatch*; this complements that with the *known-set* +/// check and does not duplicate it.) Parts whose folder cph-model already +/// flagged as missing are skipped for the rest of the pipeline. +/// - **(c) schema** — for each part whose folder exists and whose kind is +/// known, `cph_schema::validate(&part.descriptor)`. +/// - **(d) typst compile** — only if phases (a)–(c) produced **zero** errors +/// (otherwise the compile is noise on known-broken input). Compiles each +/// declared `lesson.targets` (defaulting to `"student"` if none), deduping +/// identical diagnostics across targets. +/// - **(e) render-coverage** — a `warning` (never an error) for each +/// `(kind, target)` the target's `covers` declaration does not include. Runs +/// regardless of (d) gating. +/// +/// The engine is taken as a parameter so the caller owns it (and tests can +/// inject `Engine::with_render_dir`). +pub fn check(root: &Path, engine: &Engine) -> CheckReport { + let mut diags = Vec::new(); + + // (a) load. + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + // Hard manifest failure: nothing else can run. + return CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }; + }; + + let known = cph_schema::known_kinds(); + + // (b) structural + (c) schema. + run_structural_and_schema(&lesson, known, &mut diags); + + // (d) typst compile — gated on zero errors from (a)–(c). + let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error); + if !pre_compile_has_error { + for target in compile_targets(&lesson) { + diags.extend(engine.compile_check(&lesson, target)); + } + } + + // (e) render-coverage — always runs; only a warning. Skips parts already + // broken in (b) (missing folder / unknown kind). + diags.extend(render_coverage(&lesson, known)); + + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + } +} + +/// Build a PDF for `target`. +/// +/// Runs the check phases **(a)–(c)** (load → structural → schema). If those +/// produce any `Error`, the build is refused: returns `(None, report)` with the +/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target` +/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`, +/// and on a typst error returns `(None, report)` with the compile diagnostics +/// folded into the report. +/// +/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings +/// describe export *loss*, which is informational for a `check`, not a gate on +/// producing a single target's PDF. +pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, CheckReport) { + let mut diags = Vec::new(); + + // (a) load. + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + let known = cph_schema::known_kinds(); + + // (b) structural + (c) schema. + run_structural_and_schema(&lesson, known, &mut diags); + + if diags.iter().any(|d| d.severity == Severity::Error) { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ); + } + + // Phases passed: compile + export the requested target. + match engine.build_pdf(&lesson, target) { + Ok(bytes) => ( + Some(bytes), + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ), + Err(compile_diags) => { + diags.extend(compile_diags); + ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ) + } + } +} + +/// One shell step's execution outcome (for [`run_shell_target`]). +#[derive(Debug, Clone, PartialEq)] +pub struct ShellStepOutcome { + /// The command line that was run (verbatim from the manifest `run` field). + pub run: String, + /// Process exit code (`None` if the process was killed by a signal). + pub status: Option, + /// Captured stdout. + pub stdout: String, + /// Captured stderr. + pub stderr: String, +} + +impl ShellStepOutcome { + /// Whether the step exited successfully (status 0). + pub fn ok(&self) -> bool { + self.status == Some(0) + } +} + +/// The result of [`run_shell_target`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ShellRunReport { + /// The check phases' report (load → structural → schema). Shell steps run + /// only when this has no errors. + pub check: CheckReport, + /// Each shell step's outcome, in declared order. Empty if the build was + /// refused (check errors, unknown target, or the target has no shell steps). + pub outcomes: Vec, + /// `true` if the target was found, check passed, and every shell step exited 0. + pub ok: bool, +} + +/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009 +/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI). +/// +/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a +/// typst template to a PDF), a shell target hands off to an external tool that +/// writes files itself. cph only *runs the declared command* in the engineering +/// root and reports what happened — it assembles nothing. +/// +/// Contract / safety: +/// - Runs the check phases first (load → structural → schema). Any error refuses +/// the run (`ok == false`, no command executed) — a shell build of a broken +/// lesson is not attempted. +/// - Each `Step::Shell { run }` is executed via the platform shell +/// (`sh -c ` / `cmd /C `) with the **engineering root as the working +/// directory**, so a manifest can use root-relative paths. +/// - **Arbitrary command execution is opt-in by construction**: this function is +/// only reached from `cph build --target `, never from `check`. Callers +/// (the CLI) surface the command before running it. +/// - `TypstCompile` steps inside the same target are skipped here (a shell target +/// is not a typst build); a target with no shell steps yields `ok == false`. +pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport { + let mut diags = Vec::new(); + + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + outcomes: Vec::new(), + ok: false, + }; + }; + + let known = cph_schema::known_kinds(); + run_structural_and_schema(&lesson, known, &mut diags); + + // Suppress the unused-parameter warning while keeping the signature uniform + // with `build`/`check` (the engine is not needed to run shell steps, but + // callers pass it so the API is consistent and future steps may use it). + let _ = engine; + + let has_error = diags.iter().any(|d| d.severity == Severity::Error); + + let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{target}' not declared in manifest"), + ) + .with_hint(format!( + "add a `[targets.{target}]` table, or run a declared target" + )), + ); + return ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + }; + + let shell_steps: Vec<&str> = tc + .steps + .iter() + .filter_map(|s| match s { + cph_model::Step::Shell { run } => Some(run.as_str()), + cph_model::Step::TypstCompile { .. } => None, + cph_model::Step::AssembleMarkdown { .. } => None, + }) + .collect(); + + if has_error || shell_steps.is_empty() { + return ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + } + + // Run each shell step in the engineering root. First non-zero exit stops the + // sequence (a failed step's successors likely depend on it). + let mut outcomes = Vec::new(); + let mut all_ok = true; + for run in shell_steps { + let outcome = run_one_shell(&lesson.root, run); + let ok = outcome.ok(); + outcomes.push(outcome); + if !ok { + all_ok = false; + break; + } + } + + ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes, + ok: all_ok, + } +} + +/// Whether the named target is a **shell-step** target (its steps contain at +/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated +/// asset bundle that [`run_shell_target`] executes rather than [`build`] +/// compiling to PDF. +/// +/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an +/// unknown target returns `false`, so the caller falls through to the normal +/// (typst) build path, which then reports the real error. +pub fn target_is_shell(root: &Path, target: &str) -> bool { + let (Some(lesson), _) = cph_model::load(root) else { + return false; + }; + lesson + .targets + .iter() + .find(|t| t.name == target) + .is_some_and(|t| { + t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. })) + && !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) + }) +} + +/// Run one shell command line in `cwd` via the platform shell, capturing output. +fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome { + use std::process::Command; + + let output = if cfg!(target_os = "windows") { + Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output() + } else { + Command::new("sh").arg("-c").arg(run).current_dir(cwd).output() + }; + + match output { + Ok(out) => ShellStepOutcome { + run: run.to_string(), + status: out.status.code(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }, + Err(e) => ShellStepOutcome { + run: run.to_string(), + status: None, + stdout: String::new(), + stderr: format!("failed to spawn command: {e}"), + }, + } +} + +/// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]). +#[derive(Debug, Clone, PartialEq)] +pub struct MarkdownAssembleOutcome { + /// The field name that was assembled (verbatim from the step's `field`). + pub field: String, + /// How many elements' `.md` files were read and concatenated. + pub parts_read: usize, + /// The assembled markdown body (empty if nothing was read). + pub body: String, + /// Where the assembled file was written (relative to the engineering root); + /// `None` if the write failed or was skipped. + pub written: Option, + /// `None` on success; an error message if reading a required file or writing + /// the artifact failed. + pub error: Option, +} + +impl MarkdownAssembleOutcome { + /// Whether the step succeeded (wrote the artifact with no error). + pub fn ok(&self) -> bool { + self.error.is_none() && self.written.is_some() + } +} + +/// The result of [`run_markdown_assemble_target`]. +#[derive(Debug, Clone, PartialEq)] +pub struct MarkdownAssembleReport { + /// The check phases' report (load → structural → schema). Assembly runs only + /// when this has no errors. + pub check: CheckReport, + /// Each assemble-markdown step's outcome, in declared order. Empty if the + /// build was refused (check errors, unknown target, no assemble steps). + pub outcomes: Vec, + /// `true` if the target was found, check passed, and every assemble step wrote + /// its artifact without error. + pub ok: bool, +} + +/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each +/// element's `.md` markdown content file in `[[parts]]` order into the +/// target's single-file artifact. This is the **third typed step**: unlike +/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write +/// itself (not a typst compile, not an external tool like [`run_shell_target`]). +/// +/// Contract / safety (same three boundaries as the shell step, ADR-0013/0015): +/// - Runs the check phases first (load → structural → schema). Any error refuses +/// the run (`ok == false`, nothing written) — assembling a broken lesson is +/// not attempted. +/// - Walks `lesson.parts` **in declared order**, reading `/.md` +/// when it exists (skipping absent ones — these fields are optional), joining +/// them with a blank line, and writing to the artifact's single-file +/// `filepath` (relative to the engineering root). Each `slides.md`/etc. +/// authors its own heading; the assembler injects nothing (presentation in the +/// content, ADR-0011). +/// - **Opt-in by construction**: only reached from `cph build --target `, +/// never from `check`. A non-typst target, so `check`'s compile phase skips it. +/// - A write error is a **build-process error**, not a lesson diagnostic — it is +/// reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy. +pub fn run_markdown_assemble_target( + root: &Path, + engine: &Engine, + target: &str, +) -> MarkdownAssembleReport { + let mut diags = Vec::new(); + + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return MarkdownAssembleReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + outcomes: Vec::new(), + ok: false, + }; + }; + + let known = cph_schema::known_kinds(); + run_structural_and_schema(&lesson, known, &mut diags); + + // Signature uniform with `build`/`run_shell_target`; the engine is not used + // to assemble markdown (no typst compile), but callers pass it for API + // consistency. + let _ = engine; + + let has_error = diags.iter().any(|d| d.severity == Severity::Error); + + let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{target}' not declared in manifest"), + ) + .with_hint(format!( + "add a `[targets.{target}]` table, or run a declared target" + )), + ); + return MarkdownAssembleReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + }; + + let assemble_steps: Vec<&str> = tc + .steps + .iter() + .filter_map(|s| match s { + cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()), + _ => None, + }) + .collect(); + + if has_error || assemble_steps.is_empty() { + return MarkdownAssembleReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + } + + // Run each assemble-markdown step. First failure stops the sequence. + let mut outcomes = Vec::new(); + let mut all_ok = true; + for field in assemble_steps { + let outcome = assemble_one(&lesson, field); + let ok = outcome.ok(); + outcomes.push(outcome); + if !ok { + all_ok = false; + break; + } + } + + MarkdownAssembleReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes, + ok: all_ok, + } +} + +/// Assemble one markdown `field` across all parts in order, writing the +/// single-file artifact. +fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome { + let mut chunks: Vec = Vec::new(); + let mut parts_read = 0usize; + + // Inject the course-level title as the document's h1. The title is course + // metadata (from `[info]` in the manifest), not per-element content, so the + // assembler prepends it; each element's `.md` then contributes its + // `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the + // content" is about per-element presentation; the single course title is a + // document-level fact the assembler owns.) + if !lesson.info.title.trim().is_empty() { + chunks.push(format!("# {}", lesson.info.title.trim())); + } + + for part in &lesson.parts { + if !part.descriptor.dir.is_dir() { + continue; + } + let md = part.descriptor.dir.join(format!("{field}.md")); + if md.is_file() { + match std::fs::read_to_string(&md) { + Ok(body) => { + chunks.push(body); + parts_read += 1; + } + Err(e) => { + return MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body: String::new(), + written: None, + error: Some(format!("failed to read '{}': {e}", md.display())), + }; + } + } + } + // Absent `.md` is fine — the field is optional (ADR-0015). + } + + let body = chunks.join("\n\n"); + + // Resolve the target's single-file artifact path. (The assemble target is a + // single-file artifact by construction; a file-tree target has no single + // filepath to write to, which is a manifest error surfaced here.) + let tc = lesson.targets.iter().find(|t| { + t.steps + .iter() + .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field)) + }); + let Some(tc) = tc else { + return MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: None, + error: Some("no target carries this assemble-markdown step".into()), + }; + }; + let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else { + return MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: None, + error: Some(format!( + "target '{}' has a non-single-file artifact; assemble-markdown writes a single file", + tc.name + )), + }; + }; + + let out_path = lesson.root.join(filepath); + if let Some(parent) = out_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: None, + error: Some(format!("failed to create '{}': {e}", parent.display())), + }; + } + } + match std::fs::write(&out_path, &body) { + Ok(()) => { + // Collect referenced image assets so the build dir is self-contained. + // The assembled `body` is written under `build_root` (the artifact + // filepath's parent); its `![](rel)` image references resolve against + // that build root. So each referenced local image is copied from the + // engineering root (`lesson.root/`) into the build root + // (`build_root/`), preserving its relative path. A referenced + // image that is missing at the engineering root is a build-process + // error (a self-contained build with a dangling ref is broken) — it + // does not enter the 6-class diagnostic taxonomy (ADR-0015). + if let Some(build_root) = out_path.parent() { + if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) { + return MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: Some(filepath.display().to_string()), + error: Some(e), + }; + } + } + MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: Some(filepath.display().to_string()), + error: None, + } + } + Err(e) => MarkdownAssembleOutcome { + field: field.to_string(), + parts_read, + body, + written: None, + error: Some(format!("failed to write '{}': {e}", out_path.display())), + }, + } +} + +/// Copy each local markdown image reference in `body` from the engineering root +/// `eng_root` into the build root `build_root`, preserving the reference's +/// relative path. Returns `Err(message)` on the first referenced image that is +/// missing at `eng_root` (a build-process error, ADR-0015). +/// +/// References are `![alt](target)` markdown images; `target` is taken up to the +/// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs +/// (`http://`, `https://`, `data:`) are left untouched — they are not local +/// assets to collect. Duplicate references copy once. +fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> { + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for target in image_refs(body) { + if target.is_empty() + || target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with("data:") + { + continue; + } + if !seen.insert(target.to_string()) { + continue; + } + let src = eng_root.join(target); + if !src.is_file() { + return Err(format!( + "slides references image '{}' which is missing from the engineering root", + src.display() + )); + } + let dst = build_root.join(target); + if let Some(parent) = dst.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return Err(format!("failed to create '{}': {e}", parent.display())); + } + } + if let Err(e) = std::fs::copy(&src, &dst) { + return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display())); + } + } + Ok(()) +} + +/// Scan `body` for markdown image references `![alt](target)` and yield each +/// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid +/// pulling a regex dependency for a single, simple pattern. +fn image_refs(body: &str) -> Vec<&str> { + let bytes = body.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + // Look for the `![` opener. + if bytes[i] == b'!' && bytes[i + 1] == b'[' { + // Find the matching `]`. + if let Some(bracket_close) = body[i + 2..].find(']') { + let after_bracket = i + 2 + bracket_close + 1; + if after_bracket < bytes.len() && bytes[after_bracket] == b'(' { + // Find the closing `)` of the link target. + if let Some(paren_close) = body[after_bracket + 1..].find(')') { + let target_start = after_bracket + 1; + let target_end = target_start + paren_close; + let target = &body[target_start..target_end]; + // Trim a possible ` "title"` suffix: take up to first space. + let target = target.split_whitespace().next().unwrap_or(""); + out.push(target); + i = target_end + 1; + continue; + } + } + } + } + i += 1; + } + out +} + +/// Whether the named target is an **assemble-markdown** target (its steps +/// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) — +/// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds +/// rather than [`build`] compiling to PDF. +/// +/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an +/// unknown target returns `false`, so the caller falls through to the normal +/// (typst) build path, which then reports the real error. +pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool { + let (Some(lesson), _) = cph_model::load(root) else { + return false; + }; + lesson + .targets + .iter() + .find(|t| t.name == target) + .is_some_and(|t| { + t.steps + .iter() + .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. })) + && !t.steps + .iter() + .any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) + }) +} + +/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing` +/// for parts whose folder is absent, so the orchestrator skips those parts in +/// the structural/schema/coverage phases to avoid piling diagnostics on the +/// same root cause. +fn part_exists(p: &cph_model::Part) -> bool { + p.descriptor.dir.is_dir() +} + +/// Phases (b) + (c): the known-kind structural check and per-part schema +/// validation. Shared by [`check`] and [`build`] so their gating stays +/// identical. +fn run_structural_and_schema( + lesson: &cph_model::Lesson, + known: &[&str], + diags: &mut Vec, +) { + // (b) structural: known-kind check. + for part in &lesson.parts { + if !part_exists(part) { + continue; + } + if !known.contains(&part.kind.as_str()) { + diags.push( + Diagnostic::error( + DiagCode::UnknownKind, + format!( + "part '{}' has unknown kind '{}'", + part.path.display(), + part.kind + ), + ) + .with_hint(format!("valid kinds are: {}", known.join(", "))), + ); + } + } + + // (c) schema: validate each existing part with a known kind. Skipping + // unknown kinds avoids a second `UnknownKind` from `cph_schema::validate` + // (already reported in (b)). + for part in &lesson.parts { + if !part_exists(part) || !known.contains(&part.kind.as_str()) { + continue; + } + diags.extend(cph_schema::validate(&part.descriptor)); + } +} + +/// The targets to compile-check for [`check`]: every declared **typst-compile** +/// target, or `["student"]` if the lesson declares none. +/// +/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle — +/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is +/// **not** typst-compiled, so it is excluded here: compile-checking it would +/// wrongly route a non-typst build through the typst engine. Such targets are +/// executed only on an explicit `cph build --target ` (see +/// [`run_shell_target`]); `check` validates the lesson structure, not the +/// outcome of running an external tool. +fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { + if lesson.targets.is_empty() { + return vec![DEFAULT_TARGET]; + } + lesson + .targets + .iter() + .filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))) + .map(|t| t.name.as_str()) + .collect() +} + +/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, +/// emit a `RenderIgnored` **warning** when the target does not cover that kind. +/// +/// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's +/// `RenderConfig.covers k t` in `Export/Render.lean`): a target's +/// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not +/// in that list draws the warning. An **absent** `covers` (the loader's `None`) +/// is resolved here — the orchestrator owns the kind universe via `cph-schema` — +/// to "covers all known kinds", so an undeclared target is fully covering rather +/// than covering nothing. (An explicit `covers = []` stays empty: it covers +/// nothing on purpose.) Parts already broken in (b) (missing folder / unknown +/// kind) are skipped — they are reported there. +fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec { + let mut out = Vec::new(); + // De-dupe (kind, target) pairs so we emit one warning per pair, not per part. + let mut seen: Vec<(String, String)> = Vec::new(); + + for part in &lesson.parts { + // Skip parts already flagged structurally. + if !part_exists(part) || !known.contains(&part.kind.as_str()) { + continue; + } + for target in &lesson.targets { + // Resolve the coverage set: an absent declaration defaults to the + // full known-kind universe; an explicit list (possibly empty) is + // taken verbatim. + let covered = match &target.covers { + None => true, + Some(kinds) => kinds.iter().any(|k| k == &part.kind), + }; + if covered { + continue; + } + let key = (part.kind.clone(), target.name.clone()); + if seen.contains(&key) { + continue; + } + seen.push(key); + let mut d = Diagnostic::warning( + DiagCode::RenderIgnored, + format!( + "kind '{}' is not covered by target '{}'; it will be omitted from that export", + part.kind, target.name + ), + ) + .with_hint(format!( + "add '{}' to `covers` under target '{}', or remove the target", + part.kind, target.name + )); + // Pin the severity via the named contract const. `Diagnostic::warning` + // already produces a warning, but assigning the const keeps the + // alignment to `renderIgnoredSeverity` load-bearing and greppable. + d.severity = RENDER_IGNORED_SEVERITY; + out.push(d); + } + } + out +} + +/// Deduplicate diagnostics that are identical (same code+severity+message+span+ +/// hint). The same typst error can surface once per compiled target, and the +/// same root cause can be reached by more than one phase; callers see each +/// distinct problem once. +fn dedup(diags: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(diags.len()); + for d in diags { + if !out.contains(&d) { + out.push(d); + } + } + out +} diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs new file mode 100644 index 0000000..a834da2 --- /dev/null +++ b/crates/cph-check/tests/pipeline.rs @@ -0,0 +1,672 @@ +//! Integration tests for the `cph-check` orchestrator. +//! +//! These exercise phase gating and the public API against the shared `mini` +//! fixture (`crates/cph-typst/tests/fixtures/mini/`) and small throwaway +//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real +//! `render/` package via `Engine::with_render_dir`. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_typst::Engine; + +/// `/../cph-typst/tests/fixtures/mini` — the known-good lesson. +fn mini_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("cph-typst") + .join("tests") + .join("fixtures") + .join("mini") +} + +/// `/../../render` — the repo render package. +fn render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} + +fn engine() -> Engine { + Engine::with_render_dir(render_dir()) +} + +#[test] +fn good_fixture_has_no_errors() { + let report = cph_check::check(&mini_fixture(), &engine()); + assert!(report.lesson_loaded, "mini fixture should load"); + assert_eq!( + report.error_count(), + 0, + "mini fixture should have zero errors, got: {:#?}", + report.diagnostics + ); + assert!(!report.has_errors()); +} + +#[test] +fn unknown_kind_is_an_error() { + // Build a throwaway lesson whose part declares kind "frob". + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "broken" +name = "broken" + +[info] +title = "broken" + +[[parts]] +kind = "frob" +path = "elements/widget" +"#, + ) + .unwrap(); + let part_dir = tmp.join("elements").join("widget"); + std::fs::create_dir_all(&part_dir).unwrap(); + std::fs::write(part_dir.join("element.toml"), "kind = \"frob\"\n").unwrap(); + + let report = cph_check::check(&tmp, &engine()); + assert!(report.lesson_loaded); + assert!(report.has_errors()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.code == DiagCode::UnknownKind && d.message.contains("frob")), + "expected an UnknownKind error mentioning 'frob', got: {:#?}", + report.diagnostics + ); + cleanup(&tmp); +} + +#[test] +fn hard_manifest_failure_sets_lesson_not_loaded() { + let tmp = tempdir(); + // No manifest.toml at all → cph_model::load returns None. + let report = cph_check::check(&tmp, &engine()); + assert!(!report.lesson_loaded, "missing manifest is a hard failure"); + assert!(report.has_errors()); + cleanup(&tmp); +} + +#[test] +fn build_returns_pdf_on_good_fixture() { + let (pdf, report) = cph_check::build(&mini_fixture(), &engine(), "student"); + assert!(report.lesson_loaded); + assert_eq!( + report.error_count(), + 0, + "no check errors expected, got: {:#?}", + report.diagnostics + ); + let pdf = pdf.expect("expected PDF bytes for the good fixture"); + assert!(pdf.starts_with(b"%PDF"), "output should be a PDF"); + assert!(pdf.len() > 1000, "PDF should be non-trivial"); +} + +#[test] +fn build_returns_none_on_broken_fixture() { + // A part folder is missing → cph-model flags PartPathMissing (an error), + // so build refuses before ever compiling. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "broken" +name = "broken" + +[info] +title = "broken" + +[[parts]] +kind = "segment" +path = "segments/does-not-exist" +"#, + ) + .unwrap(); + + let (pdf, report) = cph_check::build(&tmp, &engine(), "student"); + assert!(pdf.is_none(), "broken lesson must not produce a PDF"); + assert!(report.has_errors()); + cleanup(&tmp); +} + +/// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]` +/// body spliced in. Returns nothing; the caller runs `check`. +fn write_segment_lesson_with_target(tmp: &PathBuf, target_body: &str) { + std::fs::write( + tmp.join("manifest.toml"), + format!( + r#" +[project] +id = "cov" +name = "cov" + +[info] +title = "cov" + +[[parts]] +kind = "segment" +path = "segments/intro" + +{target_body} +"# + ), + ) + .unwrap(); + let part_dir = tmp.join("segments").join("intro"); + std::fs::create_dir_all(&part_dir).unwrap(); + std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(part_dir.join("textbook.typ"), "Hello.\n").unwrap(); +} + +#[test] +fn covers_omitting_used_kind_warns_render_ignored() { + // A target that declares `covers = ["example"]` does NOT cover the lesson's + // `segment` part → exactly one RenderIgnored warning (non-blocking). + let tmp = tempdir(); + write_segment_lesson_with_target( + &tmp, + r#"[targets.handout] +artifact = { type = "single-file", filepath = "build/handout.pdf" } +covers = ["example"] +[[targets.handout.steps]] +type = "typst-compile" +template = "exports/handout.typ" +"#, + ); + + let report = cph_check::check(&tmp, &engine()); + assert!(report.lesson_loaded); + let render_ignored: Vec<_> = report + .diagnostics + .iter() + .filter(|d| d.code == DiagCode::RenderIgnored) + .collect(); + assert_eq!( + render_ignored.len(), + 1, + "expected exactly one RenderIgnored warning, got: {:#?}", + report.diagnostics + ); + // It is a non-blocking warning, so legality is unaffected by it. + assert_eq!( + report.warning_count(), + 1, + "the coverage gap is a warning: {:#?}", + report.diagnostics + ); + cleanup(&tmp); +} + +#[test] +fn covers_including_used_kind_has_no_render_ignored() { + // A target that covers `segment` (the only used kind) → no RenderIgnored. + let tmp = tempdir(); + write_segment_lesson_with_target( + &tmp, + r#"[targets.handout] +artifact = { type = "single-file", filepath = "build/handout.pdf" } +covers = ["segment"] +[[targets.handout.steps]] +type = "typst-compile" +template = "exports/handout.typ" +"#, + ); + + let report = cph_check::check(&tmp, &engine()); + assert!(report.lesson_loaded); + assert!( + !report + .diagnostics + .iter() + .any(|d| d.code == DiagCode::RenderIgnored), + "a covering target should yield no RenderIgnored, got: {:#?}", + report.diagnostics + ); + cleanup(&tmp); +} + +#[test] +fn absent_covers_defaults_to_full_coverage() { + // No `covers` key at all → the orchestrator defaults to all known kinds, so + // even a non-"student"/"teacher" target name covers `segment`. (This is the + // behavior change: coverage now follows the declaration/default, not a + // hardcoded target-name allowlist.) + let tmp = tempdir(); + write_segment_lesson_with_target( + &tmp, + r#"[targets.handout] +artifact = { type = "single-file", filepath = "build/handout.pdf" } +[[targets.handout.steps]] +type = "typst-compile" +template = "exports/handout.typ" +"#, + ); + + let report = cph_check::check(&tmp, &engine()); + assert!(report.lesson_loaded); + assert!( + !report + .diagnostics + .iter() + .any(|d| d.code == DiagCode::RenderIgnored), + "an absent `covers` defaults to full coverage, got: {:#?}", + report.diagnostics + ); + cleanup(&tmp); +} + +/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the +/// given command. Returns nothing; the caller runs `run_shell_target`. +fn write_shell_target_lesson(tmp: &PathBuf, run: &str) { + std::fs::write( + tmp.join("manifest.toml"), + format!( + r#" +[project] +id = "sh" +name = "sh" + +[info] +title = "sh" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[targets.assets] +artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }} +[[targets.assets.steps]] +type = "shell" +run = "{run}" +"# + ), + ) + .unwrap(); + let part_dir = tmp.join("segments").join("intro"); + std::fs::create_dir_all(&part_dir).unwrap(); + std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap(); +} + +#[test] +fn target_is_shell_detects_shape() { + let tmp = tempdir(); + write_shell_target_lesson(&tmp, "true"); + assert!(cph_check::target_is_shell(&tmp, "assets")); + assert!(!cph_check::target_is_shell(&tmp, "nonexistent")); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_executes_in_engineering_root() { + // The command writes a file under the engineering root; cwd must be the root. + let tmp = tempdir(); + write_shell_target_lesson( + &tmp, + "mkdir -p build/assets && printf ok > build/assets/marker.txt", + ); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(report.ok, "shell target should succeed: {:#?}", report); + assert_eq!(report.outcomes.len(), 1); + assert!(report.outcomes[0].ok()); + + // The side effect landed in the engineering root. + let marker = tmp.join("build").join("assets").join("marker.txt"); + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok"); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_reports_nonzero_exit() { + let tmp = tempdir(); + write_shell_target_lesson(&tmp, "exit 3"); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(!report.ok, "non-zero exit must fail the run"); + assert_eq!(report.outcomes.len(), 1); + assert_eq!(report.outcomes[0].status, Some(3)); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_refuses_broken_lesson() { + // A part path that doesn't exist → check error → shell steps never run. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "sh" +name = "sh" + +[info] +title = "sh" + +[[parts]] +kind = "segment" +path = "segments/missing" + +[targets.assets] +artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" } +[[targets.assets.steps]] +type = "shell" +run = "printf SHOULD_NOT_RUN > build/ran.txt" +"#, + ) + .unwrap(); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(!report.ok); + assert!(report.outcomes.is_empty(), "no command should run on a broken lesson"); + assert!(report.check.has_errors()); + assert!( + !tmp.join("build").join("ran.txt").exists(), + "the command must not have executed" + ); + cleanup(&tmp); +} + +// --- assemble-markdown target (ADR-0015) --------------------------------------- + +/// Write a two-segment lesson where each segment carries a `slides.md` markdown +/// content file, plus a `slides` target that assembles them. The `which` slice +/// controls which segments get a `slides.md` (so the "skip absent" path can be +/// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`. +fn write_markdown_assemble_target_lesson(tmp: &PathBuf, slides: &[(&str, &str)]) { + let mut parts = String::new(); + for (i, (name, _)) in slides.iter().enumerate() { + if i > 0 { + parts.push('\n'); + } + parts.push_str(&format!( + "[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" + )); + } + std::fs::write( + tmp.join("manifest.toml"), + format!( + r#" +[project] +id = "md" +name = "md" + +[info] +title = "md" + +{parts} + +[targets.slides] +artifact = {{ type = "single-file", filepath = "build/slides.md" }} +[[targets.slides.steps]] +type = "assemble-markdown" +field = "slides" +"# + ), + ) + .unwrap(); + for (name, body) in slides { + let dir = tmp.join("segments").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); + std::fs::write(dir.join("slides.md"), body).unwrap(); + } +} + +#[test] +fn target_is_markdown_assemble_detects_shape() { + let tmp = tempdir(); + write_markdown_assemble_target_lesson( + &tmp, + &[("a", "# A"), ("b", "# B")], + ); + assert!(cph_check::target_is_markdown_assemble(&tmp, "slides")); + // The student target doesn't exist here → falls through to typst path → false. + assert!(!cph_check::target_is_markdown_assemble(&tmp, "student")); + cleanup(&tmp); +} + +#[test] +fn run_markdown_assemble_target_concatenates_in_parts_order() { + let tmp = tempdir(); + write_markdown_assemble_target_lesson( + &tmp, + &[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")], + ); + + let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); + assert!(report.ok, "assembly should succeed: {:#?}", report); + assert_eq!(report.outcomes.len(), 1); + let o = &report.outcomes[0]; + assert!(o.ok()); + assert_eq!(o.parts_read, 2); + assert_eq!(o.field, "slides"); + + let out = tmp.join("build").join("slides.md"); + let body = std::fs::read_to_string(&out).unwrap(); + // Both parts present, in declared order, joined by a blank line. + let intro_idx = body.find("规则一").unwrap(); + let rule2_idx = body.find("规则二").unwrap(); + assert!(intro_idx < rule2_idx, "parts must keep manifest order"); + assert!(body.contains("$8\\times$"), "KaTeX source survives as text"); + cleanup(&tmp); +} + +#[test] +fn run_markdown_assemble_target_skips_parts_without_the_field() { + // Only the first segment has a slides.md; the second is skipped (optional). + let tmp = tempdir(); + let mut parts = String::new(); + parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); + parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); + std::fs::write( + tmp.join("manifest.toml"), + format!( + r#" +[project] +id = "md" +name = "md" + +[info] +title = "md" + +{parts} + +[targets.slides] +artifact = {{ type = "single-file", filepath = "build/slides.md" }} +[[targets.slides.steps]] +type = "assemble-markdown" +field = "slides" +"# + ), + ) + .unwrap(); + for name in ["a", "b"] { + let dir = tmp.join("segments").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); + } + // Only `a` gets a slides.md. + std::fs::write(tmp.join("segments/a/slides.md"), "# A only\n").unwrap(); + + let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); + assert!(report.ok, "{:#?}", report); + assert_eq!(report.outcomes[0].parts_read, 1); + let body = std::fs::read_to_string(tmp.join("build/slides.md")).unwrap(); + // The assembler prepends the course title (info.title = "md") as an h1, + // then the single part's slides.md. + assert_eq!(body, "# md\n\n# A only\n"); + cleanup(&tmp); +} + +#[test] +fn run_markdown_assemble_target_copies_referenced_images() { + // A slides.md that references two local images; both must be copied from + // the engineering root into the build root (same relative path) so the + // build dir is self-contained (ADR-0015). + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "md" +name = "md" + +[info] +title = "md" + +[[parts]] +kind = "segment" +path = "segments/a" + +[targets.slides] +artifact = { type = "single-file", filepath = "build/slides.md" } +[[targets.slides.steps]] +type = "assemble-markdown" +field = "slides" +"#, + ) + .unwrap(); + let dir = tmp.join("segments").join("a"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); + std::fs::write( + dir.join("slides.md"), + "## A\n\n![one](assets/img/one.png)\n\n![two](assets/img/two.png)\n", + ) + .unwrap(); + // The two referenced images exist at the engineering root. + std::fs::create_dir_all(tmp.join("assets/img")).unwrap(); + std::fs::write(tmp.join("assets/img/one.png"), b"PNG1").unwrap(); + std::fs::write(tmp.join("assets/img/two.png"), b"PNG2").unwrap(); + + let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); + assert!(report.ok, "{:#?}", report); + + // Both images copied into the build root, preserving the assets/img/ path. + assert_eq!( + std::fs::read(tmp.join("build/assets/img/one.png")).unwrap(), + b"PNG1" + ); + assert_eq!( + std::fs::read(tmp.join("build/assets/img/two.png")).unwrap(), + b"PNG2" + ); + cleanup(&tmp); +} + +#[test] +fn run_markdown_assemble_target_fails_on_missing_referenced_image() { + // A slides.md references an image that does NOT exist at the engineering + // root → a build-process error (self-contained build would be broken), + // not a lesson diagnostic. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "md" +name = "md" + +[info] +title = "md" + +[[parts]] +kind = "segment" +path = "segments/a" + +[targets.slides] +artifact = { type = "single-file", filepath = "build/slides.md" } +[[targets.slides.steps]] +type = "assemble-markdown" +field = "slides" +"#, + ) + .unwrap(); + let dir = tmp.join("segments").join("a"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap(); + std::fs::write( + dir.join("slides.md"), + "## A\n\n![ghost](assets/img/ghost.png)\n", + ) + .unwrap(); + // No image created → reference is dangling. + + let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); + assert!(!report.ok, "a missing referenced image must fail the build"); + let o = &report.outcomes[0]; + assert!(o.error.as_deref().unwrap_or("").contains("ghost.png")); + // The markdown was still written, but the image was not (it doesn't exist). + assert!(tmp.join("build/slides.md").is_file()); + cleanup(&tmp); +} + +#[test] +fn run_markdown_assemble_target_refuses_broken_lesson() { + // A part path that doesn't exist → check error → assembly never runs. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "md" +name = "md" + +[info] +title = "md" + +[[parts]] +kind = "segment" +path = "segments/missing" + +[targets.slides] +artifact = { type = "single-file", filepath = "build/slides.md" } +[[targets.slides.steps]] +type = "assemble-markdown" +field = "slides" +"#, + ) + .unwrap(); + + let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); + assert!(!report.ok); + assert!(report.outcomes.is_empty(), "no assembly on a broken lesson"); + assert!(report.check.has_errors()); + assert!( + !tmp.join("build").join("slides.md").exists(), + "nothing should have been written" + ); + cleanup(&tmp); +} + +// --- tiny temp-dir helpers (no external dev-dep) ------------------------------ + +fn tempdir() -> PathBuf { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!( + "cph-check-test-{nanos}-{:?}", + std::thread::current().id() + )); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn cleanup(p: &PathBuf) { + let _ = std::fs::remove_dir_all(p); +} diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml new file mode 100644 index 0000000..ba6f955 --- /dev/null +++ b/crates/cph-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cph-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "cph" +path = "src/main.rs" + +[dependencies] +cph-check = { path = "../cph-check" } +cph-diag = { workspace = true } +cph-typst = { path = "../cph-typst" } +clap = { version = "4", features = ["derive"] } diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs new file mode 100644 index 0000000..10a0936 --- /dev/null +++ b/crates/cph-cli/src/main.rs @@ -0,0 +1,231 @@ +//! `cph` — the command-line entrypoint for the checker. +//! +//! Owned by **WU-5**. Parses args, constructs the typst [`Engine`], runs the +//! `cph-check` pipeline against an engineering file, and prints diagnostics. +//! +//! Convention: **diagnostics go to stderr, results go to stdout.** `check` +//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit +//! 0); `build` exits 1 when the PDF could not be produced. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use cph_check::CheckReport; +use cph_typst::Engine; + +/// The `cph` checker for curriculum engineering files. +#[derive(Debug, Parser)] +#[command(name = "cph", version, about, long_about = None)] +struct Cli { + /// Override the `cph-render` package directory (the folder containing + /// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution + /// (`CPH_RENDER_DIR` env var, else the repo `render/`). + #[arg(long, global = true, value_name = "DIR")] + render_dir: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Run the full check pipeline and print diagnostics. Exits 1 on any error. + Check { + /// Path to the engineering-file root (the folder with `manifest.toml`). + path: PathBuf, + }, + /// Build a PDF for a render target. Exits 1 if the build fails. + Build { + /// Path to the engineering-file root (the folder with `manifest.toml`). + path: PathBuf, + /// Render target to export. + #[arg(long, default_value = "student")] + target: String, + /// Output PDF path. Defaults to `/build/.pdf`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let engine = match &cli.render_dir { + Some(dir) => Engine::with_render_dir(dir.clone()), + None => Engine::new(), + }; + + match cli.command { + Command::Check { path } => run_check(&path, &engine), + Command::Build { path, target, out } => run_build(&path, &engine, &target, out), + } +} + +/// Print every diagnostic in `report` to stderr, followed by a summary line. +fn print_diagnostics(report: &CheckReport) { + for d in &report.diagnostics { + eprintln!("{d}"); + } + eprintln!( + "{} error{}, {} warning{}", + report.error_count(), + plural(report.error_count()), + report.warning_count(), + plural(report.warning_count()), + ); +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { + let report = cph_check::check(path, engine); + print_diagnostics(&report); + + if report.has_errors() { + ExitCode::FAILURE + } else { + println!("check passed: {}", path.display()); + ExitCode::SUCCESS + } +} + +fn run_build( + path: &std::path::Path, + engine: &Engine, + target: &str, + out: Option, +) -> ExitCode { + // A target whose steps are shell commands (a tool-generated asset bundle, + // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by + // executing those commands, not by compiling a typst template. Detect that + // shape up front and route accordingly. + if cph_check::target_is_shell(path, target) { + return run_shell_build(path, engine, target); + } + + // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 + // transcript surfaces) is built by concatenating per-element `.md` + // files in parts order, not by compiling a typst template. + if cph_check::target_is_markdown_assemble(path, target) { + return run_markdown_assemble_build(path, engine, target); + } + + let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); + + let (pdf, report) = cph_check::build(path, engine, target); + print_diagnostics(&report); + + match pdf { + Some(bytes) => { + if let Some(parent) = out_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "error: cannot create output directory '{}': {e}", + parent.display() + ); + return ExitCode::FAILURE; + } + } + if let Err(e) = std::fs::write(&out_path, &bytes) { + eprintln!("error: cannot write '{}': {e}", out_path.display()); + return ExitCode::FAILURE; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + ExitCode::SUCCESS + } + None => { + eprintln!("build failed: {} errors", report.error_count()); + ExitCode::FAILURE + } + } +} + +/// Run a shell-step target: execute its declared commands in the engineering +/// root. Arbitrary command execution is **opt-in** — it only happens on an +/// explicit `cph build --target `, and each command is printed before it +/// runs so the user sees exactly what is executed. +fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { + eprintln!("running shell-step target '{target}' (commands execute in {})", path.display()); + let report = cph_check::run_shell_target(path, engine, target); + print_diagnostics(&report.check); + + if report.outcomes.is_empty() && !report.ok { + // Refused before running anything (check errors / unknown target / no + // shell steps): the diagnostics above explain why. + if report.check.has_errors() { + eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count()); + } else { + eprintln!("build failed: target '{target}' has no shell steps to run"); + } + return ExitCode::FAILURE; + } + + for outcome in &report.outcomes { + eprintln!("$ {}", outcome.run); + if !outcome.stdout.trim().is_empty() { + print!("{}", outcome.stdout); + } + if !outcome.ok() { + eprint!("{}", outcome.stderr); + eprintln!( + "step failed (exit {})", + outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into()) + ); + } + } + + if report.ok { + println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len()); + ExitCode::SUCCESS + } else { + eprintln!("shell-step target '{target}' failed"); + ExitCode::FAILURE + } +} + +/// Run an assemble-markdown target: concatenate each element's `.md` +/// (in parts order) and write the single-file artifact (ADR-0015). Like the +/// shell path, this is opt-in — only on an explicit `cph build --target `, +/// and never in `check`. +fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { + eprintln!("assembling markdown target '{target}' (reading parts under {})", path.display()); + let report = cph_check::run_markdown_assemble_target(path, engine, target); + print_diagnostics(&report.check); + + if report.outcomes.is_empty() && !report.ok { + if report.check.has_errors() { + eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count()); + } else { + eprintln!("build failed: target '{target}' has no assemble-markdown steps to run"); + } + return ExitCode::FAILURE; + } + + for outcome in &report.outcomes { + eprintln!( + "assembled field '{}' from {} part(s)", + outcome.field, outcome.parts_read + ); + if let Some(out_rel) = &outcome.written { + println!("wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len()); + } + if let Some(err) = &outcome.error { + eprintln!("assemble failed: {err}"); + } + } + + if report.ok { + println!("assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len()); + ExitCode::SUCCESS + } else { + eprintln!("assemble-markdown target '{target}' failed"); + ExitCode::FAILURE + } +} diff --git a/crates/cph-diag/Cargo.toml b/crates/cph-diag/Cargo.toml new file mode 100644 index 0000000..fe44e86 --- /dev/null +++ b/crates/cph-diag/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cph-diag" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/cph-diag/src/lib.rs b/crates/cph-diag/src/lib.rs new file mode 100644 index 0000000..d7d7224 --- /dev/null +++ b/crates/cph-diag/src/lib.rs @@ -0,0 +1,277 @@ +//! `cph-diag` — the shared diagnostic vocabulary for the checker. +//! +//! Every other crate in the workspace depends on these types to report +//! problems. The vocabulary is intentionally small and stable: a [`Severity`] +//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an +//! optional [`SourceSpan`] pointing back at the offending source, and a +//! [`Diagnostic`] tying them together with a human message and a fix hint. +//! +//! All public types derive `Serialize` (downstream prints diagnostics as JSON), +//! plus `Debug`, `Clone`, `PartialEq`, `Eq`. + +use std::fmt; +use std::ops::Range; +use std::path::PathBuf; + +use serde::Serialize; + +/// Severity of a diagnostic. +/// +/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic +/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is +/// exactly: +/// +/// ```text +/// inductive Severity where +/// | warning +/// | error +/// ``` +/// +/// This two-valued shape is a **contract decision**, not an accident: the Lean +/// module pins `Severity` to exactly `warning | error` and states the finer +/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore +/// do **not** add an info/note level here. `error` blocks (the artifact is +/// invalid); `warning` does not block (the artifact still exports, but with +/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). +/// +/// There is no CI gate enforcing this alignment (see the repo constitution); +/// it is maintained by review, which is why this correspondence is documented +/// here rather than only in the spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Severity { + /// Non-blocking: the artifact still exports, but is lossy / has an ignored + /// element. Mirrors Lean `Severity.warning`. + Warning, + /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`. + Error, +} + +/// A location in a source file within the engineering-file tree. +/// +/// `file` is relative to the engineering-file root so spans are portable and +/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range` +/// is a 0-based byte offset range into the file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceSpan { + /// Path to the file, relative to the engineering-file root. + pub file: PathBuf, + /// Byte offset range into the file (0-based). + pub byte_range: Range, + /// 1-based line number. + pub line: u32, + /// 1-based column number. + pub col: u32, +} + +/// Stable, machine-readable diagnostic codes. +/// +/// This set is **closed**: codes are part of the checker's public contract, so +/// downstream tooling can match on the string form (see [`DiagCode::as_str`]). +/// Do not invent codes outside this enum without a deliberate decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum DiagCode { + /// A `[[parts]]` entry references a folder/path that does not exist. + PartPathMissing, + /// An element declares a `kind` that is not a known kind. + UnknownKind, + /// A required `content` `.typ` file (per the kind schema) is missing. + MissingContentFile, + /// Instance data does not conform to its kind's JSON Schema. + SchemaViolation, + /// The assembled typst source failed to compile. + /// + /// Includes **reference-resolution** failures — an unresolved cross-reference + /// (`@ref`) or a relative `import`/`include` that escapes the import boundary + /// or names a missing file. typst detects all of these at compile time, so + /// they surface here rather than as a separate diagnostic (ADR-0012 folded + /// the former `DanglingReference` class into this one). + TypstCompile, + /// An element is ignored under a render target (ADR-0005: warning). + RenderIgnored, +} + +impl DiagCode { + /// The stable string form of this code, e.g. `"E-PART-PATH"`. + /// + /// The `E-` / `W-` prefix reflects the *typical* severity of the code but + /// is not authoritative — the actual severity lives on the [`Diagnostic`]. + pub fn as_str(&self) -> &'static str { + match self { + DiagCode::PartPathMissing => "E-PART-PATH", + DiagCode::UnknownKind => "E-UNKNOWN-KIND", + DiagCode::MissingContentFile => "E-MISSING-CONTENT", + DiagCode::SchemaViolation => "E-SCHEMA", + DiagCode::TypstCompile => "E-TYPST-COMPILE", + DiagCode::RenderIgnored => "W-RENDER-IGNORED", + } + } +} + +impl fmt::Display for DiagCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A single diagnostic emitted by the checker. +/// +/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an +/// optional [`SourceSpan`] / hint with the builder methods +/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Diagnostic { + /// How serious this diagnostic is. + pub severity: Severity, + /// The stable machine code. + pub code: DiagCode, + /// Human-readable message. + pub message: String, + /// Optional location this diagnostic points at. + pub span: Option, + /// Optional fix hint — the product's core value. + pub hint: Option, +} + +impl Diagnostic { + /// Create an `error`-severity diagnostic. + pub fn error(code: DiagCode, message: impl Into) -> Self { + Diagnostic { + severity: Severity::Error, + code, + message: message.into(), + span: None, + hint: None, + } + } + + /// Create a `warning`-severity diagnostic. + pub fn warning(code: DiagCode, message: impl Into) -> Self { + Diagnostic { + severity: Severity::Warning, + code, + message: message.into(), + span: None, + hint: None, + } + } + + /// Attach a source span (builder-style). + pub fn with_span(mut self, span: SourceSpan) -> Self { + self.span = Some(span); + self + } + + /// Attach a fix hint (builder-style). + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Severity::Warning => "warning", + Severity::Error => "error", + }) + } +} + +impl fmt::Display for Diagnostic { + /// Renders one line `error[E-PART-PATH]: `, plus an indented + /// `--> file:line:col` line when a span is present, and a `hint: ...` line + /// when a hint is present. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?; + if let Some(span) = &self.span { + write!( + f, + "\n --> {}:{}:{}", + span.file.display(), + span.line, + span.col + )?; + } + if let Some(hint) = &self.hint { + write!(f, "\n hint: {hint}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_as_str_is_stable() { + assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH"); + assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED"); + assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA"); + // Display matches as_str. + assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND"); + } + + #[test] + fn error_constructor() { + let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'"); + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.code, DiagCode::UnknownKind); + assert_eq!(d.message, "no such kind 'frob'"); + assert!(d.span.is_none()); + assert!(d.hint.is_none()); + } + + #[test] + fn warning_constructor() { + let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); + assert_eq!(d.severity, Severity::Warning); + assert_eq!(d.code, DiagCode::RenderIgnored); + } + + #[test] + fn builders_attach_span_and_hint() { + let span = SourceSpan { + file: PathBuf::from("segments/intro/textbook.typ"), + byte_range: 10..20, + line: 3, + col: 5, + }; + let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket") + .with_span(span.clone()) + .with_hint("close the '[' opened on line 2"); + assert_eq!(d.span, Some(span)); + assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2")); + } + + #[test] + fn display_bare() { + let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found"); + assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found"); + } + + #[test] + fn display_with_span_and_hint() { + let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found") + .with_span(SourceSpan { + file: PathBuf::from("manifest.toml"), + byte_range: 0..0, + line: 12, + col: 3, + }) + .with_hint("create the folder 'segments/intro' or fix the path"); + assert_eq!( + d.to_string(), + "error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path" + ); + } + + #[test] + fn display_warning() { + let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); + assert_eq!( + d.to_string(), + "warning[W-RENDER-IGNORED]: ignored under 'teacher'" + ); + } +} diff --git a/crates/cph-model/Cargo.toml b/crates/cph-model/Cargo.toml new file mode 100644 index 0000000..145ca5c --- /dev/null +++ b/crates/cph-model/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cph-model" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +cph-diag = { workspace = true } +serde = { workspace = true } +# `preserve_order` keeps `[targets.*]` tables in TOML document order, which is +# the declared order this crate exposes via `Lesson.targets` (ADR-0009). +toml = { workspace = true, features = ["preserve_order"] } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs new file mode 100644 index 0000000..b3c4d10 --- /dev/null +++ b/crates/cph-model/src/lib.rs @@ -0,0 +1,1028 @@ +//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson. +//! +//! This crate is the **loader**, not the full checker. It reads +//! `/manifest.toml` (project / info / ordered `[[parts]]` / declared +//! `[targets.*]`) and each part's `//element.toml`, and produces an +//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)` +//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries +//! teaching semantics. +//! +//! Scope boundaries (deliberately staying in lane): +//! - It validates **structure** only: manifest shape, element.toml shape, and +//! the cross-check `part.kind == element.toml kind`. +//! - It does **not** validate instance data against a kind's JSON Schema (that +//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files +//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4). +//! +//! Entry point: [`load`]. + +use std::path::{Component, Path, PathBuf}; + +use cph_diag::{DiagCode, Diagnostic}; +use serde::{Deserialize, Serialize}; + +/// An ordered, in-memory lesson loaded from an engineering file. +/// +/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered +/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest +/// is declarative" — the `[[parts]]` array order is the single source of truth). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Lesson { + /// `[project]` from the manifest (id, name). + pub project: Project, + /// `[info]` from the manifest (title, optional author). + pub info: Info, + /// The ordered parts — the lesson's element sequence. + pub parts: Vec, + /// Declared export targets, collected from the `[targets.]` tables. + /// + /// Per ADR-0009/0011 an export target is a *build* producing a typed + /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those + /// in declared (TOML document) order. See [`TargetConfig`]. + pub targets: Vec, + /// Engineering-file root (absolute), for resolving part paths. + pub root: PathBuf, +} + +impl Lesson { + /// The declared export-target names, in declared order. + /// + /// Convenience for callers that only need the names (the shape this crate + /// exposed before ADR-0009 turned `targets` into structured + /// [`TargetConfig`]s). + pub fn target_names(&self) -> Vec<&str> { + self.targets.iter().map(|t| t.name.as_str()).collect() + } +} + +/// One declared export target's build config (ADR-0009/0011). +/// +/// An export target is a **build** producing a typed [`Artifact`] via an +/// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that +/// build: the artifact carries fields (where the product lands / which files it +/// is), and the build is a step list, each step a typed operation. Presentation +/// (heading numbering, styling) lives in the `typstCompile` template file, not +/// in the manifest — so there is no `numbering` field here anymore. +/// +/// ## Defaults for a minimal manifest +/// +/// - A target with **no `artifact`** key defaults to +/// [`Artifact::SingleFile`] with `filepath = "build/.pdf"`, so an +/// almost-empty `[targets.student]` still produces a usable build. +/// - A target with **no `steps`** defaults to a single +/// [`Step::TypstCompile`] with `template = "exports/.typ"` (the +/// framework's stock per-target template). +/// - A target with **no `covers`** key leaves [`TargetConfig::covers`] `None`, +/// meaning "coverage not declared". The consumer (`cph-check`) resolves that +/// to the full known-kind universe — see the field doc. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TargetConfig { + /// Target name, e.g. `"student"` (the `[targets.]` table key). + pub name: String, + /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`] + /// at `build/.pdf` when the `artifact` field is absent. + pub artifact: Artifact, + /// The ordered build steps. Defaults to a single [`Step::TypstCompile`] + /// with template `exports/.typ` when no `[[steps]]` are given. + pub steps: Vec, + /// The **render-coverage declaration**: which element kinds this target + /// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop` + /// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render + /// coverage is a declaration, not a payload": the contract keeps *which + /// kinds a target renders* (used by the `renderIgnored` seed diagnostic), + /// while the rendering "how" lives in the template/steps. + /// + /// `Some(kinds)` is the explicit manifest list (`covers = ["segment", …]`). + /// `None` means the `covers` key was **absent**; the loader does not own the + /// kind universe (it must not depend on `cph-schema`), so it records the + /// absence and lets `cph-check` default it to all known kinds. An empty + /// `Some(vec![])` is distinct: a target that explicitly covers nothing. + pub covers: Option>, +} + +/// The artifact an export target produces (ADR-0009/0011). +/// +/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master +/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly: +/// +/// ```text +/// inductive Artifact where +/// | singleFile (filepath : String) +/// | fileTree (root : String) (outputs : String) +/// ``` +/// +/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product +/// is" — one file at some path vs. a tree of files under some root matching a +/// glob — is the non-obvious domain semantics that must be in the type, not +/// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案 +/// PDF); `FileTree` is a tree of files (a third-party-platform archive). The +/// on-disk `artifact` inline table's `type` discriminator selects the variant: +/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → +/// [`Artifact::FileTree`]. +/// +/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this +/// alignment (see the repo constitution) — it is maintained by review, which is +/// why the correspondence is documented here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Artifact { + /// One bundled document landing at `filepath` (relative to the engineering + /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape. + SingleFile { + /// Where the single product is written (relative to the engineering + /// root), e.g. `build/student.pdf`. + filepath: PathBuf, + }, + /// A set of files under `root` matching the `outputs` glob. Mirrors Lean + /// `Artifact.fileTree`. + FileTree { + /// The output directory (relative to the engineering root). + root: PathBuf, + /// A **glob** describing which files this build produces, e.g. + /// `**/*.{html,js,json}` — kept as a string (the contract pins the + /// field, not a path/glob algebra). + outputs: String, + }, +} + +impl Artifact { + /// The default artifact when `artifact` is omitted: a single file at + /// `build/.pdf`. + fn default_for(name: &str) -> Artifact { + Artifact::SingleFile { + filepath: PathBuf::from(format!("build/{name}.pdf")), + } + } +} + +/// One typed build step (ADR-0011). +/// +/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master +/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly: +/// +/// ```text +/// inductive Step where +/// | typstCompile (template : String) +/// | shell (run : String) +/// | assembleMarkdown (field : String) +/// ``` +/// +/// A step is a *typed* operation (extensible): `TypstCompile` compiles a +/// template file — typed, not a raw shell line, because the framework must wire +/// the manifest into it (`--input manifest=…`), which a bare `typst compile` +/// string cannot express. `Shell` is the escape hatch for steps that resist +/// declaration (ADR-0005's medium-only category (b): HTML interactive / npm +/// builds). `AssembleMarkdown` concatenates a per-element markdown content +/// field into a single-file markdown deliverable (ADR-0015: the slides outline +/// and 逐字稿 transcript surfaces, authored directly in markdown+KaTeX to +/// sidestep the typst→md conversion). The on-disk `[[steps]]` entry's `type` +/// discriminator selects the variant: `"typst-compile"` → +/// [`Step::TypstCompile`], `"shell"` → [`Step::Shell`], `"assemble-markdown"` +/// → [`Step::AssembleMarkdown`]. +/// +/// As with [`Artifact`], this alignment is maintained by review, not CI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Step { + /// Compile a template file (relative to the engineering root) into the + /// artifact; the framework injects the manifest. Mirrors Lean + /// `Step.typstCompile`. + TypstCompile { + /// The template file to compile as main, e.g. `exports/student.typ`. + template: PathBuf, + }, + /// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`. + Shell { + /// The command line to run. + run: String, + }, + /// Assemble a single-file markdown deliverable by concatenating each + /// element's `field` markdown content file in `[[parts]]` order. Mirrors + /// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the + /// framework owns the read/concatenate/write itself. + AssembleMarkdown { + /// The per-element markdown content field to assemble (e.g. `slides`, + /// `transcript`). + field: String, + }, +} + +impl Step { + /// The default step when no `[[steps]]` are given: compile the stock + /// per-target template `exports/.typ`. + fn default_for(name: &str) -> Step { + Step::TypstCompile { + template: PathBuf::from(format!("exports/{name}.typ")), + } + } +} + +/// `[project]` table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Project { + /// Stable project id. + pub id: String, + /// Folder / display name. + pub name: String, +} + +/// `[info]` table (passed through to render targets verbatim). +/// +/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master +/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose +/// `authors` is always a list. The authoring-surface form (string-or-array +/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this +/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No +/// CI gate enforces this alignment (repo constitution); it is kept greppable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Info { + /// Lesson title. + pub title: String, + /// Authors, in declared order. A lesson can have several (a teaching group), + /// so this is a list, not a single name. Empty when `[info]` declares no + /// `author`. The on-disk `author` accepts either a bare string (one author) + /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. + /// Mirrors Lean `Info.authors : List String`. + pub authors: Vec, +} + +/// One `[[parts]]` entry plus its loaded element descriptor. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Part { + /// Declared kind from the manifest (one of the known kinds; ADR-0006). + pub kind: String, + /// Element folder path **as written in the manifest** (relative to root), + /// kept verbatim for diagnostics / display. + pub path: PathBuf, + /// The element's self-description loaded from its `element.toml`. + pub descriptor: ElementDescriptor, +} + +/// An element's `element.toml`, parsed into kind + remaining scalar fields. +/// +/// ADR-0008: `kind` is explicit in the descriptor (not inferred from the parent +/// directory), so a folder is self-describing. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ElementDescriptor { + /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is + /// reported as a diagnostic (see [`load`]). + pub kind: String, + /// Element folder, absolute path. + pub dir: PathBuf, + /// The remaining `element.toml` keys (the scalar fields). Schema validation + /// of these is WU-3's job, not this loader's. + pub scalars: toml::Table, +} + +// --- raw deserialization shapes (mirror the on-disk TOML) --------------------- + +#[derive(Debug, Deserialize)] +struct RawManifest { + project: Option, + info: Option, + #[serde(default)] + parts: Vec, + #[serde(default)] + targets: toml::Table, +} + +#[derive(Debug, Deserialize)] +struct RawProject { + id: String, + name: String, +} + +/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in +/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for +/// fill-in convenience, normalized into the canonical [`Info`] at the load +/// boundary. Not the form the rest of the model traffics in. +#[derive(Debug, Deserialize)] +struct RawInfo { + title: String, + author: Option, +} + +/// On-disk `author`: either a single name (`author = "…"`) or a list +/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose +/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] +/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawAuthor { + One(String), + Many(Vec), +} + +impl RawAuthor { + /// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single + /// name becomes a one-element list; a list passes through verbatim. + fn into_vec(self) -> Vec { + match self { + RawAuthor::One(s) => vec![s], + RawAuthor::Many(v) => v, + } + } +} + +#[derive(Debug, Deserialize)] +struct RawPart { + kind: String, + path: String, +} + +/// Load the engineering file at `root` into a [`Lesson`]. +/// +/// Returns `(Option, Vec)`: +/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The +/// structure is produced even when individual parts have problems, so the +/// WU-5 orchestrator gets **both** the partial lesson and the collected +/// diagnostics. +/// - `None` only on a hard failure where no `Lesson` can be built: the +/// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the +/// required `[project]` / `[info]` tables. In that case the diagnostics +/// describe the hard failure. +/// +/// Collected (non-fatal) diagnostics include: a part path that does not exist +/// or escapes the root via `..`, a missing/malformed `element.toml`, and a +/// `part.kind != element.toml kind` mismatch. +/// +/// ## Diagnostic-code mapping (judgment call, WU-1) +/// +/// `cph-diag`'s code set is closed and has **no** dedicated "manifest +/// malformed" code. We deliberately do not invent one here. Until such a code +/// is added, manifest-level structural errors (bad TOML, missing `[project]` / +/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`], +/// with a message making the real cause clear. A genuinely missing **part +/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing / +/// unreadable / malformed `element.toml` also maps to `SchemaViolation`. +// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so +// manifest-structure errors don't overload `SchemaViolation`. +pub fn load(root: &Path) -> (Option, Vec) { + let mut diags = Vec::new(); + + let manifest_path = root.join("manifest.toml"); + + let manifest_src = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("cannot read manifest.toml: {e}"), + ) + .with_hint(format!( + "expected an engineering-file manifest at {}", + manifest_path.display() + )), + ); + return (None, diags); + } + }; + + let raw: RawManifest = match toml::from_str(&manifest_src) { + Ok(r) => r, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("manifest.toml is not valid TOML: {e}"), + ) + .with_hint("fix the TOML syntax in manifest.toml"), + ); + return (None, diags); + } + }; + + // [project] and [info] are required to build a Lesson at all. + let project = match raw.project { + Some(p) => Project { + id: p.id, + name: p.name, + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + "manifest.toml is missing the required [project] table", + ) + .with_hint("add a [project] table with `id` and `name`"), + ); + return (None, diags); + } + }; + + let info = match raw.info { + Some(i) => Info { + title: i.title, + authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(), + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + "manifest.toml is missing the required [info] table", + ) + .with_hint("add an [info] table with at least `title`"), + ); + return (None, diags); + } + }; + + // Parse each [targets.] table into a structured build config + // (ADR-0009/0011). Order is the TOML document order, as before. Malformed + // target config is non-fatal: it is reported and the target is kept with + // defaults so loading continues. + let targets: Vec = raw + .targets + .into_iter() + .map(|(name, value)| parse_target(name, value, &mut diags)) + .collect(); + + // Load each part. Problems are collected, not fatal: we still build the + // Part (with a best-effort descriptor) so order/membership is observable. + let mut parts = Vec::with_capacity(raw.parts.len()); + for raw_part in raw.parts { + let rel_path = PathBuf::from(&raw_part.path); + + // Reject `..` traversal: a part path must stay within the root. + if has_parent_traversal(&rel_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "part path '{}' escapes the engineering-file root via '..'", + raw_part.path + ), + ) + .with_hint("part paths must be relative folders inside the engineering file"), + ); + // Still record the part with an empty descriptor so order is kept. + parts.push(Part { + kind: raw_part.kind.clone(), + path: rel_path.clone(), + descriptor: ElementDescriptor { + kind: raw_part.kind, + dir: root.join(&rel_path), + scalars: toml::Table::new(), + }, + }); + continue; + } + + let dir = root.join(&rel_path); + + let descriptor = if !dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("part folder '{}' does not exist", raw_part.path), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in manifest.toml", + raw_part.path + )), + ); + ElementDescriptor { + kind: raw_part.kind.clone(), + dir, + scalars: toml::Table::new(), + } + } else { + load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags) + }; + + parts.push(Part { + kind: raw_part.kind, + path: rel_path, + descriptor, + }); + } + + let lesson = Lesson { + project, + info, + parts, + targets, + root: root.to_path_buf(), + }; + (Some(lesson), diags) +} + +/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting +/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back +/// to the part's declared kind with empty scalars so the lesson stays buildable. +fn load_descriptor( + dir: &Path, + rel_path: &Path, + part_kind: &str, + diags: &mut Vec, +) -> ElementDescriptor { + let element_toml = dir.join("element.toml"); + let rel_element_toml = rel_path.join("element.toml"); + + let src = match std::fs::read_to_string(&element_toml) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "cannot read element.toml for part '{}': {e}", + rel_path.display() + ), + ) + .with_hint(format!( + "add an element.toml at {} with at least `kind = \"{part_kind}\"`", + rel_element_toml.display() + )), + ); + return ElementDescriptor { + kind: part_kind.to_string(), + dir: dir.to_path_buf(), + scalars: toml::Table::new(), + }; + } + }; + + let mut table: toml::Table = match toml::from_str(&src) { + Ok(t) => t, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' is not valid TOML: {e}", + rel_path.display() + ), + ) + .with_hint("fix the TOML syntax in element.toml"), + ); + return ElementDescriptor { + kind: part_kind.to_string(), + dir: dir.to_path_buf(), + scalars: toml::Table::new(), + }; + } + }; + + // `kind` is required and must be a string. Pull it out of the scalar map so + // `scalars` ends up being "the remaining keys". + let kind = match table.remove("kind") { + Some(toml::Value::String(k)) => k, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' has a non-string `kind`", + rel_path.display() + ), + ) + .with_hint("`kind` must be a string, e.g. `kind = \"lemma\"`"), + ); + part_kind.to_string() + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' is missing the required `kind` key", + rel_path.display() + ), + ) + .with_hint(format!("add `kind = \"{part_kind}\"` to element.toml")), + ); + part_kind.to_string() + } + }; + + // Cross-check: the descriptor's kind must equal the part's declared kind. + if kind != part_kind { + diags.push( + Diagnostic::error( + DiagCode::UnknownKind, + format!( + "kind mismatch for part '{}': manifest says '{part_kind}', element.toml says '{kind}'", + rel_path.display() + ), + ) + .with_hint("make the manifest `kind` and the element.toml `kind` agree"), + ); + } + + ElementDescriptor { + kind, + dir: dir.to_path_buf(), + scalars: table, + } +} + +/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009/0011), +/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. +/// +/// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the +/// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the +/// default single `typst-compile` step ([`Step::default_for`]) — all reported, +/// never dropping the target. An empty `[targets.]` body is valid (all +/// defaults, no diagnostics). +fn parse_target(name: String, value: toml::Value, diags: &mut Vec) -> TargetConfig { + // A target body must be a table; anything else (`student = 1`) is malformed. + let mut table = match value { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' must be a table, e.g. `[targets.{name}]`"), + ) + .with_hint("declare a target as a `[targets.]` table"), + ); + return TargetConfig { + artifact: Artifact::default_for(&name), + steps: vec![Step::default_for(&name)], + covers: None, + name, + }; + } + }; + + let artifact = match table.remove("artifact") { + None => Artifact::default_for(&name), + Some(value) => parse_artifact(&name, value, diags), + }; + + let steps = match table.remove("steps") { + None => vec![Step::default_for(&name)], + Some(value) => parse_steps(&name, value, diags), + }; + + let covers = match table.remove("covers") { + None => None, + Some(value) => parse_covers(&name, value, diags), + }; + + TargetConfig { + name, + artifact, + steps, + covers, + } +} + +/// Parse a target's optional `covers` array into the render-coverage declaration +/// (ADR-0011). Each entry is a kind name (a string). A non-array `covers`, or any +/// non-string entry, is reported as a [`DiagCode::SchemaViolation`]; in that case +/// the declaration falls back to `None` ("not declared"), so `cph-check` defaults +/// it to the full known-kind universe rather than silently covering nothing. +/// +/// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that +/// explicitly renders no kind (every used kind then draws a `renderIgnored` +/// warning) — distinct from an absent key. +fn parse_covers(name: &str, value: toml::Value, diags: &mut Vec) -> Option> { + let items = match value { + toml::Value::Array(items) => items, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-array `covers`; declare it as a list of \ + kind names, e.g. `covers = [\"segment\", \"example\"]`" + ), + ) + .with_hint("set `covers` to an array of kind-name strings, or omit it"), + ); + return None; + } + }; + + let mut kinds = Vec::with_capacity(items.len()); + for item in items { + match item { + toml::Value::String(s) => kinds.push(s), + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-string entry in `covers`: {}", + json_like_type(&other) + ), + ) + .with_hint("each `covers` entry is a kind name (a string)"), + ); + return None; + } + } + } + Some(kinds) +} + +/// A short type label for a TOML value, for the `covers` non-string diagnostic. +fn json_like_type(v: &toml::Value) -> &'static str { + match v { + toml::Value::String(_) => "string", + toml::Value::Integer(_) => "integer", + toml::Value::Float(_) => "float", + toml::Value::Boolean(_) => "boolean", + toml::Value::Datetime(_) => "datetime", + toml::Value::Array(_) => "array", + toml::Value::Table(_) => "table", + } +} + +/// Parse the `artifact` inline table into an [`Artifact`]. The `type` +/// discriminator selects the variant and which fields are read. An unknown or +/// malformed `type`, a non-table value, or a missing required field is reported +/// as a [`DiagCode::SchemaViolation`]; in every error case a usable default +/// ([`Artifact::default_for`]) is returned so loading continues. +fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec) -> Artifact { + let mut table = match value { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-table `artifact`; it must be an \ + inline table like `{{ type = \"single-file\", filepath = \"…\" }}`" + ), + ) + .with_hint("set `artifact` to an inline table with a `type` discriminator"), + ); + return Artifact::default_for(name); + } + }; + + let kind = match table.remove("type") { + Some(toml::Value::String(s)) => s, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a non-string `artifact.type`"), + ) + .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), + ); + return Artifact::default_for(name); + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has an `artifact` with no `type` discriminator"), + ) + .with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"), + ); + return Artifact::default_for(name); + } + }; + + match kind.as_str() { + "single-file" => { + let filepath = match take_string_field(&mut table, "filepath") { + Some(s) => PathBuf::from(s), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' single-file `artifact` is missing a string \ + `filepath`" + ), + ) + .with_hint("add `filepath = \"build/.pdf\"` to the artifact"), + ); + return Artifact::default_for(name); + } + }; + Artifact::SingleFile { filepath } + } + "file-tree" => { + let root = take_string_field(&mut table, "root"); + let outputs = take_string_field(&mut table, "outputs"); + match (root, outputs) { + (Some(root), Some(outputs)) => Artifact::FileTree { + root: PathBuf::from(root), + outputs, + }, + (root, outputs) => { + let mut missing = Vec::new(); + if root.is_none() { + missing.push("root"); + } + if outputs.is_none() { + missing.push("outputs"); + } + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' file-tree `artifact` is missing required \ + string field(s): {}", + missing.join(", ") + ), + ) + .with_hint( + "a file-tree artifact needs `root = \"\"` and \ + `outputs = \"\"`", + ), + ); + Artifact::default_for(name) + } + } + } + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has an invalid `artifact.type` '{other}'; \ + valid values are \"single-file\", \"file-tree\"" + ), + ) + .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), + ); + Artifact::default_for(name) + } + } +} + +/// Parse the `steps` array-of-tables into an ordered `Vec`, preserving +/// declared order. A non-array `steps`, or any individual step with an unknown +/// or malformed `type` / missing required field, is reported as a +/// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while +/// well-formed ones are kept. If `steps` is present but yields no usable steps, +/// the default step ([`Step::default_for`]) is substituted so the build stays +/// runnable. +fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec) -> Vec { + let items = match value { + toml::Value::Array(items) => items, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-array `steps`; declare steps as \ + `[[targets.{name}.steps]]` entries" + ), + ) + .with_hint("each step is a `[[targets..steps]]` table with a `type`"), + ); + return vec![Step::default_for(name)]; + } + }; + + let mut steps = Vec::with_capacity(items.len()); + for item in items { + if let Some(step) = parse_step(name, item, diags) { + steps.push(step); + } + } + + if steps.is_empty() { + // `steps` was present but produced nothing usable: keep the build + // runnable with the default step (the malformed entries were reported). + vec![Step::default_for(name)] + } else { + steps + } +} + +/// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects +/// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`]) +/// for a non-table entry, an unknown/malformed `type`, or a missing required +/// field. +fn parse_step(name: &str, value: toml::Value, diags: &mut Vec) -> Option { + let mut table = match value { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step that is not a table"), + ) + .with_hint("each step is a `[[targets..steps]]` table with a `type`"), + ); + return None; + } + }; + + let kind = match table.remove("type") { + Some(toml::Value::String(s)) => s, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step with a non-string `type`"), + ) + .with_hint( + "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"", + ), + ); + return None; + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step with no `type` discriminator"), + ) + .with_hint( + "add `type = \"typst-compile\"` (or `\"shell\"`, `\"assemble-markdown\"`) to the step", + ), + ); + return None; + } + }; + + match kind.as_str() { + "typst-compile" => match take_string_field(&mut table, "template") { + Some(template) => Some(Step::TypstCompile { + template: PathBuf::from(template), + }), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' typst-compile step is missing a string `template`" + ), + ) + .with_hint("add `template = \"exports/.typ\"` to the step"), + ); + None + } + }, + "shell" => match take_string_field(&mut table, "run") { + Some(run) => Some(Step::Shell { run }), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' shell step is missing a string `run`"), + ) + .with_hint("add `run = \"\"` to the step"), + ); + None + } + }, + "assemble-markdown" => match take_string_field(&mut table, "field") { + Some(field) => Some(Step::AssembleMarkdown { field }), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' assemble-markdown step is missing a string `field`" + ), + ) + .with_hint("add `field = \"slides\"` (or `\"transcript\"`) to the step"), + ); + None + } + }, + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a step with an invalid `type` '{other}'; \ + valid values are \"typst-compile\", \"shell\", \"assemble-markdown\"" + ), + ) + .with_hint( + "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"", + ), + ); + None + } + } +} + +/// Remove `key` from `table` and return it if it is a string; otherwise `None` +/// (a missing key and a non-string value are both treated as absent — the +/// caller reports the missing-field diagnostic with the right context). +fn take_string_field(table: &mut toml::Table, key: &str) -> Option { + match table.remove(key) { + Some(toml::Value::String(s)) => Some(s), + _ => None, + } +} + +/// True if `path` contains any `..` component (parent-dir traversal). +fn has_parent_traversal(path: &Path) -> bool { + path.components().any(|c| matches!(c, Component::ParentDir)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_traversal_detection() { + assert!(has_parent_traversal(Path::new("../x"))); + assert!(has_parent_traversal(Path::new("a/../b"))); + assert!(!has_parent_traversal(Path::new("a/b/c"))); + assert!(!has_parent_traversal(Path::new("segments/intro"))); + } +} diff --git a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml new file mode 100644 index 0000000..cacd692 --- /dev/null +++ b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml @@ -0,0 +1,18 @@ +[project] +id = "fixture-bad-target-config" +name = "bad-target-config" + +[info] +title = "测试课:非法导出目标配置" + +# Invalid artifact `type` → SchemaViolation, falls back to default (single-file). +[targets.student] +artifact = { type = "pdf-thing", filepath = "build/student.pdf" } + +# Invalid step `type` → SchemaViolation; the bad step is skipped and the build +# falls back to the default step. +[targets.teacher] +artifact = { type = "file-tree", root = "build/teacher", outputs = "**/*.html" } +[[targets.teacher.steps]] +type = "make-it-nice" +template = "exports/teacher.typ" diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml new file mode 100644 index 0000000..fedd3aa --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-kind-mismatch" +name = "kind-mismatch" + +[info] +title = "kind 不一致测试" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml new file mode 100644 index 0000000..4f1b473 --- /dev/null +++ b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml @@ -0,0 +1,3 @@ +[project +id = "broken" +this is not valid toml = = diff --git a/crates/cph-model/tests/fixtures/missing-part/manifest.toml b/crates/cph-model/tests/fixtures/missing-part/manifest.toml new file mode 100644 index 0000000..d54f585 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/manifest.toml @@ -0,0 +1,16 @@ +[project] +id = "fixture-missing-part" +name = "missing-part" + +[info] +title = "缺部件测试" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[[parts]] +kind = "lemma" +path = "lemmas/does-not-exist" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/target-configs/manifest.toml b/crates/cph-model/tests/fixtures/target-configs/manifest.toml new file mode 100644 index 0000000..ba3bb29 --- /dev/null +++ b/crates/cph-model/tests/fixtures/target-configs/manifest.toml @@ -0,0 +1,30 @@ +[project] +id = "fixture-target-configs" +name = "target-configs" + +[info] +title = "测试课:导出目标的结构化 build 配置" +# Array form: a lesson authored by several people. +author = ["张老师", "李老师"] + +# Full v2 config: explicit single-file artifact + an ordered typst-compile step. +[targets.student] +artifact = { type = "single-file", filepath = "build/student.pdf" } +[[targets.student.steps]] +type = "typst-compile" +template = "exports/student.typ" + +# file-tree artifact + multiple steps (typst-compile then a shell step), +# exercising both step types and order preservation. +[targets.archive] +artifact = { type = "file-tree", root = "build/archive", outputs = "**/*.{html,js,json}" } +[[targets.archive.steps]] +type = "typst-compile" +template = "exports/archive.typ" +[[targets.archive.steps]] +type = "shell" +run = "npm run build" + +# Empty body → all defaults (single-file build/teacher.pdf, one typst-compile +# step exports/teacher.typ). +[targets.teacher] diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml new file mode 100644 index 0000000..7dcea61 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml @@ -0,0 +1,2 @@ +kind = "lemma" +source = "测试引理" diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml new file mode 100644 index 0000000..227556b --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -0,0 +1,18 @@ +[project] +id = "fixture-valid" +name = "valid-2-part" + +[info] +title = "测试课:两个部件" +author = "范式教育教研组" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[[parts]] +kind = "lemma" +path = "lemmas/young" + +[targets.student] +[targets.teacher] diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs new file mode 100644 index 0000000..9d2414f --- /dev/null +++ b/crates/cph-model/tests/load.rs @@ -0,0 +1,274 @@ +//! Integration tests for `cph_model::load`, driven by static fixtures under +//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 +//! on-disk format. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_model::load; + +/// Absolute path to a fixture engineering-file root. +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn valid_two_part_lesson_loads_in_order_with_no_errors() { + let (lesson, diags) = load(&fixture("valid")); + + let lesson = lesson.expect("valid fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "valid fixture must have no diagnostics, got: {diags:?}" + ); + + assert_eq!(lesson.project.id, "fixture-valid"); + assert_eq!(lesson.project.name, "valid-2-part"); + assert_eq!(lesson.info.title, "测试课:两个部件"); + // A single-string `author` loads as a one-element list. + assert_eq!(lesson.info.authors, vec!["范式教育教研组".to_string()]); + + // Parts preserve declared order: segment first, lemma second. + assert_eq!(lesson.parts.len(), 2); + assert_eq!(lesson.parts[0].kind, "segment"); + assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); + assert_eq!(lesson.parts[0].descriptor.kind, "segment"); + assert_eq!(lesson.parts[1].kind, "lemma"); + assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); + assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); + + // `source` scalar survives on the lemma descriptor; `kind` is removed. + let scalars = &lesson.parts[1].descriptor.scalars; + assert_eq!( + scalars.get("source").and_then(|v| v.as_str()), + Some("测试引理") + ); + assert!(scalars.get("kind").is_none()); + + // Targets are collected from [targets.*], in declared order. + assert_eq!(lesson.target_names(), vec!["student", "teacher"]); + // Empty `[targets.x]` bodies → all-default build config, no diagnostics: + // single-file artifact at build/.pdf + one typst-compile step. + for target in &lesson.targets { + assert_eq!( + target.artifact, + cph_model::Artifact::SingleFile { + filepath: PathBuf::from(format!("build/{}.pdf", target.name)), + } + ); + assert_eq!( + target.steps, + vec![cph_model::Step::TypstCompile { + template: PathBuf::from(format!("exports/{}.typ", target.name)), + }] + ); + } + + // Descriptor dir is absolute, anchored under the root. + assert!(lesson.parts[0].descriptor.dir.is_absolute()); + assert_eq!( + lesson.parts[0].descriptor.dir, + lesson.root.join("segments/intro") + ); +} + +#[test] +fn missing_part_folder_yields_part_path_missing() { + let (lesson, diags) = load(&fixture("missing-part")); + + // Still produces a Lesson (loader stays best-effort). + let lesson = lesson.expect("missing-part fixture must still produce a Lesson"); + assert_eq!(lesson.parts.len(), 2, "both parts are recorded for order"); + + let missing: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::PartPathMissing) + .collect(); + assert_eq!( + missing.len(), + 1, + "exactly one PartPathMissing expected, got diags: {diags:?}" + ); + assert_eq!(missing[0].severity, cph_diag::Severity::Error); +} + +#[test] +fn kind_mismatch_yields_unknown_kind_diagnostic() { + let (lesson, diags) = load(&fixture("kind-mismatch")); + + let lesson = lesson.expect("kind-mismatch fixture must still produce a Lesson"); + assert_eq!(lesson.parts.len(), 1); + + let mismatch: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::UnknownKind) + .collect(); + assert_eq!( + mismatch.len(), + 1, + "exactly one kind-mismatch diagnostic expected, got: {diags:?}" + ); + assert!( + mismatch[0].message.contains("segment") && mismatch[0].message.contains("lemma"), + "message should name both kinds, got: {}", + mismatch[0].message + ); +} + +#[test] +fn malformed_manifest_is_a_hard_failure() { + let (lesson, diags) = load(&fixture("malformed-manifest")); + + assert!( + lesson.is_none(), + "malformed manifest must be a hard failure (None)" + ); + assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].severity, cph_diag::Severity::Error); +} + +#[test] +fn missing_manifest_is_a_hard_failure() { + // A directory with no manifest.toml at all. + let (lesson, diags) = load(&fixture("does-not-exist-at-all")); + assert!(lesson.is_none()); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); +} + +#[test] +fn structured_target_configs_parse_into_typed_builds() { + use cph_model::{Artifact, Step}; + + let (lesson, diags) = load(&fixture("target-configs")); + let lesson = lesson.expect("target-configs fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "well-formed target configs must have no diagnostics, got: {diags:?}" + ); + + // An array `author` loads as the ordered author list. + assert_eq!( + lesson.info.authors, + vec!["张老师".to_string(), "李老师".to_string()] + ); + + // Declared order is preserved. + assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]); + + // student: explicit single-file artifact (with filepath) + one + // typst-compile step (with template). + let student = &lesson.targets[0]; + assert_eq!(student.name, "student"); + assert_eq!( + student.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/student.pdf"), + } + ); + assert_eq!( + student.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/student.typ"), + }] + ); + + // archive: file-tree artifact (root + outputs glob) + two ordered steps, + // a typst-compile followed by a shell step (order preserved). + let archive = &lesson.targets[1]; + assert_eq!(archive.name, "archive"); + assert_eq!( + archive.artifact, + Artifact::FileTree { + root: PathBuf::from("build/archive"), + outputs: "**/*.{html,js,json}".to_string(), + } + ); + assert_eq!( + archive.steps, + vec![ + Step::TypstCompile { + template: PathBuf::from("exports/archive.typ"), + }, + Step::Shell { + run: "npm run build".to_string(), + }, + ] + ); + + // teacher: empty body → all defaults. + let teacher = &lesson.targets[2]; + assert_eq!(teacher.name, "teacher"); + assert_eq!( + teacher.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/teacher.pdf"), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); +} + +#[test] +fn malformed_target_config_is_non_fatal_with_schema_violations() { + use cph_model::{Artifact, Step}; + + let (lesson, diags) = load(&fixture("bad-target-config")); + let lesson = lesson.expect("bad-target-config must still produce a Lesson"); + + // Both targets survive despite their malformed fields. + assert_eq!(lesson.target_names(), vec!["student", "teacher"]); + + // Bad `artifact.type` → SchemaViolation, target kept with default artifact. + let student = &lesson.targets[0]; + assert_eq!( + student.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/student.pdf"), + } + ); + + // Bad step `type` → the step is skipped; the file-tree artifact survives and + // the empty step list falls back to the default step. + let teacher = &lesson.targets[1]; + assert_eq!( + teacher.artifact, + Artifact::FileTree { + root: PathBuf::from("build/teacher"), + outputs: "**/*.html".to_string(), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); + + let violations: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::SchemaViolation) + .collect(); + assert_eq!( + violations.len(), + 2, + "expected one SchemaViolation per malformed field, got: {diags:?}" + ); + assert!( + violations.iter().any(|d| d.message.contains("pdf-thing")), + "a diagnostic should name the bad artifact type, got: {diags:?}" + ); + assert!( + violations + .iter() + .any(|d| d.message.contains("make-it-nice")), + "a diagnostic should name the bad step type, got: {diags:?}" + ); +} diff --git a/crates/cph-schema/Cargo.toml b/crates/cph-schema/Cargo.toml new file mode 100644 index 0000000..b374db4 --- /dev/null +++ b/crates/cph-schema/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cph-schema" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +serde_json = "1" +toml = { workspace = true } + +[dev-dependencies] +toml = { workspace = true } diff --git a/crates/cph-schema/schemas/example.json b/crates/cph-schema/schemas/example.json new file mode 100644 index 0000000..ee10cb0 --- /dev/null +++ b/crates/cph-schema/schemas/example.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/example", + "title": "example", + "type": "object", + "properties": { + "problem": { "type": "string", "x-cph-content": true }, + "solution": { "type": "string", "x-cph-content": true }, + "slides": { "type": "string", "x-cph-content": "md" }, + "transcript": { "type": "string", "x-cph-content": "md" }, + "source": { "type": "string" } + }, + "required": ["problem", "solution"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/lemma.json b/crates/cph-schema/schemas/lemma.json new file mode 100644 index 0000000..e5cbd6c --- /dev/null +++ b/crates/cph-schema/schemas/lemma.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/lemma", + "title": "lemma", + "type": "object", + "properties": { + "stmt": { "type": "string", "x-cph-content": true }, + "proof": { "type": "string", "x-cph-content": true }, + "slides": { "type": "string", "x-cph-content": "md" }, + "transcript": { "type": "string", "x-cph-content": "md" } + }, + "required": ["stmt"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/segment.json b/crates/cph-schema/schemas/segment.json new file mode 100644 index 0000000..ea5d53f --- /dev/null +++ b/crates/cph-schema/schemas/segment.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/segment", + "title": "segment", + "type": "object", + "properties": { + "textbook": { "type": "string", "x-cph-content": true }, + "slides": { "type": "string", "x-cph-content": "md" }, + "transcript": { "type": "string", "x-cph-content": "md" } + }, + "required": ["textbook"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/sop.json b/crates/cph-schema/schemas/sop.json new file mode 100644 index 0000000..8da118a --- /dev/null +++ b/crates/cph-schema/schemas/sop.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/sop", + "title": "sop", + "type": "object", + "properties": { + "sop": { "type": "string", "x-cph-content": true }, + "slides": { "type": "string", "x-cph-content": "md" }, + "transcript": { "type": "string", "x-cph-content": "md" } + }, + "required": ["sop"], + "additionalProperties": false +} diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs new file mode 100644 index 0000000..1932b49 --- /dev/null +++ b/crates/cph-schema/src/lib.rs @@ -0,0 +1,548 @@ +//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation. +//! +//! Owned by **WU-3**. The schema layer reads each kind's declarative JSON +//! Schema (built-in scalars plus the `content` extension type) and validates +//! element instance data against it, including which `content` `.typ` files +//! must exist. +//! +//! # The `content` extension convention +//! +//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema** +//! (draft 2020-12 here) with one extension type — `content` — whose value is a +//! Typst source rather than inline data. JSON Schema has no native `content` +//! type, so this crate marks a content field with a **custom keyword**: +//! +//! ```json +//! { "type": "string", "x-cph-content": true } +//! ``` +//! +//! The `x-cph-content: true` marker is the single convention this crate honors. +//! A field carrying it is treated as a **content field**: it does NOT appear in +//! `element.toml`; instead the validator checks that the sibling file +//! `.typ` exists in the element's directory (ADR-0008). A required +//! content field whose `.typ` file is missing yields a +//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field +//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be +//! absent with no diagnostic. All other (non-`x-cph-content`) properties are +//! **scalar** fields that live in `element.toml` and are validated structurally. +//! +//! The four stdlib kind schemas live as real JSON artifacts under +//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are +//! compiled in via `include_str!`, parsed once on first use. +//! +//! # Validation approach +//! +//! The MVP scalar surface is intentionally tiny (a single optional string on +//! `example`), so rather than pull in a full JSON Schema engine (and its +//! version churn), this crate uses `serde_json` to parse the schemas and a +//! small hand-rolled interpreter that understands exactly the keywords the +//! stdlib schemas use: `properties`, `required`, `type`, `enum`, +//! `additionalProperties`, plus the `x-cph-content` extension marker. This +//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping +//! the validator dependency-light and fully under our control. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::ElementDescriptor; +use serde_json::Value as Json; + +/// The custom JSON Schema keyword marking a field as a `content` (Typst-source) +/// field rather than a scalar. See the crate docs for the convention. +const CONTENT_MARKER: &str = "x-cph-content"; + +/// The known stdlib kinds, in a stable order. +const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"]; + +/// Raw schema JSON for each kind, embedded at compile time. +const SCHEMA_SOURCES: &[(&str, &str)] = &[ + ("segment", include_str!("../schemas/segment.json")), + ("example", include_str!("../schemas/example.json")), + ("lemma", include_str!("../schemas/lemma.json")), + ("sop", include_str!("../schemas/sop.json")), +]; + +/// The scalar `type` a property declares (the subset the stdlib schemas use). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScalarType { + String, + Number, + Integer, + Boolean, +} + +impl ScalarType { + fn from_json(s: &str) -> Option { + match s { + "string" => Some(ScalarType::String), + "number" => Some(ScalarType::Number), + "integer" => Some(ScalarType::Integer), + "boolean" => Some(ScalarType::Boolean), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + ScalarType::String => "string", + ScalarType::Number => "number", + ScalarType::Integer => "integer", + ScalarType::Boolean => "boolean", + } + } + + /// Whether `value` (a scalar coming from `element.toml`, already converted + /// to JSON) satisfies this declared type. + fn matches(self, value: &Json) -> bool { + match self { + ScalarType::String => value.is_string(), + ScalarType::Number => value.is_number(), + ScalarType::Integer => value.is_i64() || value.is_u64(), + ScalarType::Boolean => value.is_boolean(), + } + } +} + +/// One scalar property of a kind (a field that lives in `element.toml`). +#[derive(Debug, Clone)] +struct ScalarField { + name: String, + /// Declared `type`, if any. `None` means "unconstrained" (we still accept + /// the field but apply no type check). + ty: Option, + /// Allowed values from a JSON Schema `enum`, as JSON values. + allowed: Option>, + required: bool, +} + +/// One content property of a kind (a field whose value is a sibling content +/// file, marked with `x-cph-content`). The file extension is determined by the +/// marker's value: `true` (or `"typ"`) → `.typ`; any other string → that +/// extension (e.g. `"md"` → `.md`, ADR-0015 markdown content surfaces). +#[derive(Debug, Clone)] +struct ContentField { + name: String, + required: bool, + /// The content file's extension without the dot (e.g. `"typ"`, `"md"`). + ext: String, +} + +/// A parsed kind schema: the content fields (which drive `.typ` existence +/// checks) and the scalar fields (validated against `element.toml`). +#[derive(Debug, Clone)] +pub struct KindSchema { + kind: String, + content_fields: Vec, + scalar_fields: Vec, + /// Whether the schema forbids properties beyond those declared + /// (`additionalProperties: false`). + deny_additional: bool, +} + +impl KindSchema { + /// The kind name this schema describes (e.g. `"example"`). + pub fn kind(&self) -> &str { + &self.kind + } + + /// The names of the `content` fields (those requiring a `.typ` + /// file), in schema order. + pub fn content_field_names(&self) -> Vec<&str> { + self.content_fields + .iter() + .map(|f| f.name.as_str()) + .collect() + } + + /// The names of the scalar fields (those living in `element.toml`), in + /// schema order. + pub fn scalar_field_names(&self) -> Vec<&str> { + self.scalar_fields.iter().map(|f| f.name.as_str()).collect() + } + + /// Parse a kind schema from its raw JSON source. Panics on a malformed + /// schema — schemas are compile-time artifacts owned by this crate, so a + /// bad one is a build-time bug, not a runtime condition. + fn parse(kind: &str, src: &str) -> Self { + let root: Json = serde_json::from_str(src) + .unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}")); + + let deny_additional = root + .get("additionalProperties") + .and_then(Json::as_bool) + .map(|b| !b) + .unwrap_or(false); + + let required: Vec = root + .get("required") + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let is_required = |name: &str| required.iter().any(|r| r == name); + + let mut content_fields = Vec::new(); + let mut scalar_fields = Vec::new(); + + let props = root + .get("properties") + .and_then(Json::as_object) + .unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object")); + + for (name, prop) in props { + // The `x-cph-content` marker selects a content field and fixes its + // file extension. `true` (the legacy form) and the string `"typ"` + // both mean a `.typ` file; any other string is taken as the + // extension (e.g. `"md"` → `.md` for markdown content surfaces, + // ADR-0015). Absent ⇒ a scalar field. + let ext = match prop.get(CONTENT_MARKER) { + Some(Json::Bool(true)) => Some("typ".to_string()), + Some(Json::String(s)) => Some(s.clone()), + _ => None, + }; + + if let Some(ext) = ext { + content_fields.push(ContentField { + name: name.clone(), + required: is_required(name), + ext, + }); + } else { + let ty = prop + .get("type") + .and_then(Json::as_str) + .and_then(ScalarType::from_json); + let allowed = prop + .get("enum") + .and_then(Json::as_array) + .map(|a| a.to_vec()); + scalar_fields.push(ScalarField { + name: name.clone(), + ty, + allowed, + required: is_required(name), + }); + } + } + + KindSchema { + kind: kind.to_string(), + content_fields, + scalar_fields, + deny_additional, + } + } +} + +/// The lazily-built registry mapping kind name → parsed schema. +fn registry() -> &'static BTreeMap<&'static str, KindSchema> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + SCHEMA_SOURCES + .iter() + .map(|(kind, src)| (*kind, KindSchema::parse(kind, src))) + .collect() + }) +} + +/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown. +pub fn schema_for(kind: &str) -> Option<&'static KindSchema> { + registry().get(kind) +} + +/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`), +/// for use by the orchestrator (`cph-check`). +pub fn known_kinds() -> &'static [&'static str] { + KNOWN_KINDS +} + +/// Validate an element instance against its kind's schema. +/// +/// Self-contained: looks up the schema by `desc.kind` and, if the kind is +/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic. +/// Otherwise it checks, in order: +/// +/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the +/// non-content part of the schema — required-ness, declared `type`, and +/// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an +/// `additionalProperties: false` schema, an unexpected scalar key is also a +/// `SchemaViolation`. +/// 2. **Content fields**: for each required content field, that the sibling +/// file `desc.dir.join(".typ")` exists; if not, a +/// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be +/// absent with no diagnostic. +/// +/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the +/// expected path in its message; `span` is left `None` (there is no line/col +/// for a file that does not exist). +pub fn validate(desc: &ElementDescriptor) -> Vec { + let Some(schema) = schema_for(&desc.kind) else { + return vec![Diagnostic::error( + DiagCode::UnknownKind, + format!("unknown element kind '{}'", desc.kind), + ) + .with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))]; + }; + + let mut diags = Vec::new(); + validate_scalars(schema, desc, &mut diags); + validate_content_files(schema, desc, &mut diags); + diags +} + +/// Validate the scalar fields in `desc.scalars` against the schema's scalar +/// part. +fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec) { + let scalars = &desc.scalars; + + // Required scalar fields must be present. + for field in &schema.scalar_fields { + if field.required && !scalars.contains_key(&field.name) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element of kind '{}' is missing required field '{}'", + schema.kind, field.name + ), + ) + .with_hint(format!("add `{} = ...` to element.toml", field.name)), + ); + } + } + + // Each present scalar: type / enum checks, and (if known) reject unexpected + // keys when additionalProperties is false. Content fields must never appear + // in element.toml. + for (key, value) in scalars { + if let Some(field) = schema.content_fields.iter().find(|f| f.name == *key) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' is a content field and must not appear in element.toml", + key, schema.kind + ), + ) + .with_hint(format!( + "remove '{key}' from element.toml; put its content in the sibling file '{key}.{}'", + field.ext + )), + ); + continue; + } + + let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else { + if schema.deny_additional { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("unexpected field '{}' for kind '{}'", key, schema.kind), + ) + .with_hint(format!( + "remove '{key}' from element.toml (it is not part of the '{}' schema)", + schema.kind + )), + ); + } + continue; + }; + + let json = toml_to_json(value); + + if let Some(ty) = field.ty { + if !ty.matches(&json) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' must be a {}, got {}", + key, + schema.kind, + ty.label(), + json_type_label(&json) + ), + ) + .with_hint(format!( + "set '{key}' to a {} value in element.toml", + ty.label() + )), + ); + continue; + } + } + + if let Some(allowed) = &field.allowed { + if !allowed.contains(&json) { + let choices: Vec = allowed.iter().map(render_json_choice).collect(); + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' has value {} which is not one of the allowed values", + key, + schema.kind, + render_json_choice(&json) + ), + ) + .with_hint(format!("'{key}' must be one of: {}", choices.join(", "))), + ); + } + } + } +} + +/// For each required content field, check that its `.` file exists +/// in the element directory (`.typ` for typst content, `.md` for markdown +/// content surfaces — ADR-0015). +fn validate_content_files( + schema: &KindSchema, + desc: &ElementDescriptor, + diags: &mut Vec, +) { + for field in &schema.content_fields { + if !field.required { + // Optional content (e.g. lemma's `proof`): absence is fine. + continue; + } + let file_name = format!("{}.{}", field.name, field.ext); + let path = desc.dir.join(&file_name); + if !path.is_file() { + diags.push( + Diagnostic::error( + DiagCode::MissingContentFile, + format!( + "kind '{}' requires the content file '{}', which is missing", + schema.kind, + path.display() + ), + ) + .with_hint(format!( + "create '{file_name}' in the element folder with the {} source for '{}'", + ext_label(&field.ext), + field.name + )), + ); + } + } +} + +/// A human label for a content-file extension, used in diagnostic hints. +fn ext_label(ext: &str) -> &'static str { + match ext { + "md" => "markdown", + "typ" => "typst", + _ => "content", + } +} + +/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for +/// type checking. Composite TOML values (arrays / tables) are converted +/// structurally too, though the MVP stdlib schemas only use scalars. +fn toml_to_json(value: &toml::Value) -> Json { + match value { + toml::Value::String(s) => Json::String(s.clone()), + toml::Value::Integer(i) => Json::from(*i), + toml::Value::Float(f) => Json::from(*f), + toml::Value::Boolean(b) => Json::Bool(*b), + toml::Value::Datetime(dt) => Json::String(dt.to_string()), + toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()), + toml::Value::Table(tbl) => Json::Object( + tbl.iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(), + ), + } +} + +/// A short label for a JSON value's runtime type, for diagnostic messages. +fn json_type_label(value: &Json) -> &'static str { + match value { + Json::Null => "null", + Json::Bool(_) => "boolean", + Json::Number(n) => { + if n.is_i64() || n.is_u64() { + "integer" + } else { + "number" + } + } + Json::String(_) => "string", + Json::Array(_) => "array", + Json::Object(_) => "object", + } +} + +/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as +/// JSON). +fn render_json_choice(value: &Json) -> String { + match value { + Json::String(s) => format!("\"{s}\""), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_all_known_kinds() { + for kind in known_kinds() { + assert!(schema_for(kind).is_some(), "missing schema for {kind}"); + } + assert!(schema_for("frob").is_none()); + } + + #[test] + fn example_field_split() { + let s = schema_for("example").unwrap(); + // `problem`/`solution` are typst content; `slides`/`transcript` are the + // optional markdown content surfaces (ADR-0015); `source` is a scalar. + // (serde_json's Map is a BTreeMap without the `preserve_order` feature, + // so content field names come back alphabetically sorted.) + assert_eq!( + s.content_field_names(), + vec!["problem", "slides", "solution", "transcript"] + ); + assert_eq!(s.scalar_field_names(), vec!["source"]); + } + + #[test] + fn lemma_proof_is_optional_content() { + let s = schema_for("lemma").unwrap(); + let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap(); + assert!(!proof.required); + let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap(); + assert!(stmt.required); + } + + #[test] + fn markdown_content_fields_carry_md_ext() { + // ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`), + // distinct from the `.typ` content fields. + let s = schema_for("segment").unwrap(); + let slides = s.content_fields.iter().find(|f| f.name == "slides").unwrap(); + assert_eq!(slides.ext, "md"); + assert!(!slides.required); + let transcript = s + .content_fields + .iter() + .find(|f| f.name == "transcript") + .unwrap(); + assert_eq!(transcript.ext, "md"); + assert!(!transcript.required); + // Legacy typst content still resolves to `.typ`. + let textbook = s + .content_fields + .iter() + .find(|f| f.name == "textbook") + .unwrap(); + assert_eq!(textbook.ext, "typ"); + assert!(textbook.required); + } +} diff --git a/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ new file mode 100644 index 0000000..3af134b --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ @@ -0,0 +1,2 @@ += Problem +only the problem here diff --git a/crates/cph-schema/tests/fixtures/example_valid/problem.typ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ new file mode 100644 index 0000000..200e0de --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ @@ -0,0 +1,2 @@ += Problem +What is 2+2? diff --git a/crates/cph-schema/tests/fixtures/example_valid/solution.typ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ new file mode 100644 index 0000000..31152c0 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ @@ -0,0 +1,2 @@ += Solution +4. diff --git a/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ new file mode 100644 index 0000000..1ae08b6 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ @@ -0,0 +1,2 @@ += Statement +A lemma. diff --git a/crates/cph-schema/tests/validate.rs b/crates/cph-schema/tests/validate.rs new file mode 100644 index 0000000..f742d2e --- /dev/null +++ b/crates/cph-schema/tests/validate.rs @@ -0,0 +1,76 @@ +//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under +//! `tests/fixtures//` and in-code `ElementDescriptor`s. + +use std::path::{Path, PathBuf}; + +use cph_diag::DiagCode; +use cph_model::ElementDescriptor; +use cph_schema::validate; + +/// Absolute path to a fixture directory. +fn fixture(case: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(case) +} + +fn desc(kind: &str, dir: PathBuf, scalars: toml::Table) -> ElementDescriptor { + ElementDescriptor { + kind: kind.to_string(), + dir, + scalars, + } +} + +#[test] +fn valid_example_no_diagnostics() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::String("Textbook §3".into())); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn example_missing_solution_typ_is_missing_content_file() { + let d = desc( + "example", + fixture("example_missing_solution"), + toml::Table::new(), + ); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::MissingContentFile); + assert!(diags[0].message.contains("solution.typ")); + assert!(diags[0].hint.is_some()); +} + +#[test] +fn lemma_without_proof_typ_is_ok() { + // stmt.typ exists, proof.typ does not — proof is optional, so no diagnostic. + let d = desc("lemma", fixture("lemma_no_proof"), toml::Table::new()); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn unknown_kind_is_reported() { + let d = desc("frob", fixture("example_valid"), toml::Table::new()); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::UnknownKind); + assert!(diags[0].message.contains("frob")); +} + +#[test] +fn example_source_non_string_is_schema_violation() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::Integer(42)); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert!(diags[0].message.contains("source")); + assert!(diags[0].message.contains("string")); +} diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml new file mode 100644 index 0000000..08bab88 --- /dev/null +++ b/crates/cph-typst/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cph-typst" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +cph-schema = { path = "../cph-schema" } +toml = { workspace = true } + +# Embed the `render/` package (cph-render + vendored @preview/numbly) INTO the +# binary so an installed `cph` is self-contained — no reliance on a source +# checkout living at a compile-time path. `dirs` locates a per-user cache dir to +# extract the embedded tree to (typst's World reads render files from disk). +include_dir = "0.7" +dirs = "6" + +typst = "=0.15.0" +typst-layout = "=0.15.0" +typst-pdf = "=0.15.0" +typst-syntax = "=0.15.0" +typst-library = "=0.15.0" +typst-kit = { version = "=0.15.0", features = [ + "scan-fonts", + "embedded-fonts", +] } + +[dev-dependencies] +cph-model = { workspace = true } +tempfile = "3" diff --git a/crates/cph-typst/build.rs b/crates/cph-typst/build.rs new file mode 100644 index 0000000..c0e215c --- /dev/null +++ b/crates/cph-typst/build.rs @@ -0,0 +1,64 @@ +//! Build script: stage a clean copy of the repo `render/` package into `OUT_DIR` +//! so `src/embedded.rs` can `include_dir!` it without tripping over the dev-only +//! self-referential symlink at `render/vendor/local-packages/`. +//! +//! Why this exists: `include_dir!` walks a directory at compile time and follows +//! symlinks. The repo keeps `render/vendor/local-packages/local/cph-render/0.1.0` +//! as a symlink pointing back at `render/` (a gitignored dev convenience for +//! typst's local-package resolution). Embedding `render/` directly would make +//! `include_dir!` recurse into that link forever. The runtime never needs that +//! link (the engine resolves `@local/cph-render` from the render dir directly, +//! and `@preview/*` from `vendor/typst-packages/`), so we copy everything EXCEPT +//! `vendor/local-packages/` into `OUT_DIR/render` and embed that staged tree. + +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let render_src = manifest_dir.join("..").join("..").join("render"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let staged = out_dir.join("render"); + + // Rebuild the staged tree from scratch each run (cheap — ~140 KiB). + if staged.exists() { + let _ = std::fs::remove_dir_all(&staged); + } + copy_tree(&render_src, &staged).expect("stage render/ for embedding"); + + // Re-run when the render package changes. + println!("cargo:rerun-if-changed={}", render_src.display()); + // Expose the staged path to the crate via an env var include_dir! can read. + println!("cargo:rustc-env=CPH_STAGED_RENDER_DIR={}", staged.display()); +} + +/// Recursively copy `src` → `dst`, skipping the dev-only `vendor/local-packages` +/// subtree and never following symlinks into it. +fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let name = entry.file_name(); + let from = entry.path(); + let to = dst.join(&name); + + // Skip the self-referential dev symlink tree. + if from.ends_with("vendor/local-packages") || name == *"local-packages" { + continue; + } + + // Resolve symlinks/metadata without following into a cycle: use + // `symlink_metadata` so a symlinked dir is treated by its link, not its + // (recursive) target. We only copy real files and real directories. + let meta = std::fs::symlink_metadata(&from)?; + let ft = meta.file_type(); + if ft.is_symlink() { + // Skip any symlink (the only one in the tree is the dev convenience). + continue; + } else if ft.is_dir() { + copy_tree(&from, &to)?; + } else if ft.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/crates/cph-typst/src/diag.rs b/crates/cph-typst/src/diag.rs new file mode 100644 index 0000000..447c9a5 --- /dev/null +++ b/crates/cph-typst/src/diag.rs @@ -0,0 +1,120 @@ +//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s. +//! +//! ## Span mapping (typst 0.15 specifics) +//! +//! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare +//! `Span` of 0.14). To locate it we: +//! 1. `span.id() -> Option` for the owning file (`None` ⇒ detached, so +//! no span is emitted); +//! 2. `WorldExt::range(span) -> Option>` for the byte range — this +//! handles every `DiagSpanKind` variant internally (numbered source spans +//! *and* raw external byte ranges), so we never match the enum by hand; +//! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via +//! `lines().byte_to_line_column` (0-based ⇒ `+1`). +//! +//! The `main` file is now the real **template** (`exports/.typ`), so a +//! span there resolves naturally to an authored file — no re-pointing needed. A +//! span landing in the **virtual augmented manifest** ([`crate::MANIFEST_VPATH`], +//! served in-memory, not on disk) is reported with its vpath plus a hint marking +//! it as the framework-synthesized manifest (a manifest-generation bug +//! indicator) rather than mis-attributed to a user file. Spans in the +//! `cph-render` package are reported with an `@local/cph-render:…` pseudo-path. + +use std::path::PathBuf; + +use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan}; +use typst::diag::{Severity as TypstSeverity, SourceDiagnostic}; +use typst::syntax::{FileId, VirtualRoot}; +use typst::{World, WorldExt}; + +use crate::world::LessonWorld; + +/// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its +/// span against `world`. +pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic { + let severity = match d.severity { + TypstSeverity::Error => Severity::Error, + TypstSeverity::Warning => Severity::Warning, + }; + + // Flatten the typst call trace into the message tail so call-site context + // isn't lost (the primary span points at the innermost node). + let mut message = d.message.to_string(); + for tp in &d.trace { + message.push_str(&format!("\n in {}", tp.v)); + } + + let mut diag = Diagnostic { + severity, + code: DiagCode::TypstCompile, + message, + span: None, + hint: None, + }; + + // typst's own hints are the product's core value — carry them through. + let mut hints: Vec = d.hints.iter().map(|h| h.v.to_string()).collect(); + + if let Some(id) = d.span.id() { + if let Some(file) = file_for(world, id) { + if is_virtual_manifest(id) { + hints.push( + "this span is inside the framework-synthesized manifest \ + (/.cph/manifest.toml), not a lesson source file — likely a \ + manifest-generation issue" + .to_string(), + ); + } + // Byte range via WorldExt::range (DiagSpan is Copy). + let byte_range = world.range(d.span).unwrap_or(0..0); + let (line, col) = line_col(world, id, byte_range.start); + diag.span = Some(SourceSpan { + file, + byte_range, + line, + col, + }); + } + } + + if !hints.is_empty() { + diag.hint = Some(hints.join("; ")); + } + diag +} + +/// Whether `id` is the in-memory augmented-manifest virtual file +/// ([`crate::MANIFEST_VPATH`]) the engine injects (not a real lesson file). +fn is_virtual_manifest(id: FileId) -> bool { + matches!(id.root(), VirtualRoot::Project) + && id.vpath().get_with_slash() == crate::MANIFEST_VPATH +} + +/// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)` +/// when the source can't be read or the offset is out of range. +fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) { + let Ok(source) = world.source(id) else { + return (1, 1); + }; + match source.lines().byte_to_line_column(byte) { + // byte_to_line_column is 0-based; editors are 1-based. + Some((line, col)) => (line as u32 + 1, col as u32 + 1), + None => (1, 1), + } +} + +/// File path for a `FileId`. Project files are relative to the lesson root (no +/// leading slash); package files use an `@namespace/name:version/path` form. +fn file_for(world: &LessonWorld, id: FileId) -> Option { + let _ = world; + match id.root() { + VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())), + VirtualRoot::Package(spec) => Some(PathBuf::from(format!( + "@{}/{}:{}{}", + spec.namespace, + spec.name, + spec.version, + id.vpath().get_with_slash() + ))), + } +} diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs new file mode 100644 index 0000000..47fc00e --- /dev/null +++ b/crates/cph-typst/src/embedded.rs @@ -0,0 +1,104 @@ +//! Embedded `cph-render` package — makes an installed `cph` self-contained. +//! +//! The typst [`crate::world::LessonWorld`] reads the render package +//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by +//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's +//! `render/`. But a `cargo install`-ed binary has no repo around it — the old +//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox +//! and does not exist at runtime. +//! +//! So we **embed the whole `render/` tree into the binary** at compile time +//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir** +//! keyed by the crate version. `default_render_dir` returns that extracted path. +//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction +//! is a one-time cost per version. +//! +//! `render/vendor/local-packages/` (a dev-only self-referential symlink, +//! gitignored) is excluded by the build script when it stages the tree for +//! embedding — see `build.rs`. The embedded copy never contains it, which is +//! correct: the World resolves `@local/cph-render` from `render_dir` directly, +//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink. + +use std::path::{Path, PathBuf}; + +use include_dir::{include_dir, Dir}; + +/// The `render/` tree, embedded at compile time from the **staged** copy the +/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that +/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR` +/// is set by `build.rs`. +static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR"); + +/// Resolve the directory the typst World should read the render package from, +/// extracting the embedded copy to a per-user cache dir if needed. +/// +/// Resolution order: +/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point +/// at the live repo `render/`). +/// 2. The extracted embedded copy under the user cache dir +/// (`/cph/render-/`). Extracted once per crate version; +/// subsequent runs reuse it. +/// +/// On any failure to locate a cache dir or extract, falls back to a temp-dir +/// location so the engine still works (just re-extracting per process). +pub fn resolve_render_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CPH_RENDER_DIR") { + return PathBuf::from(dir); + } + ensure_extracted().unwrap_or_else(|_| { + // Last-resort: extract under the OS temp dir. Still correct, just not + // cached across processes. + let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); + let _ = extract_to(&fallback); + fallback + }) +} + +/// The version-keyed cache location and a guarantee the embedded tree is present +/// there. Returns the directory the World should use. +fn ensure_extracted() -> std::io::Result { + let base = dirs::cache_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; + let dest = base + .join("cph") + .join(format!("render-{}", env!("CARGO_PKG_VERSION"))); + + // A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by + // version, so a new `cph` version re-extracts into a fresh dir.) + let sentinel = dest.join(".extracted"); + if sentinel.is_file() { + return Ok(dest); + } + + extract_to(&dest)?; + std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; + Ok(dest) +} + +/// Write the embedded `render/` tree under `dest` (creating dirs as needed). +/// +/// Each embedded entry's `.path()` is already relative to the embed root (e.g. +/// `src/elements/segment.typ`), so we join the **full** path under `dest` and +/// create parents — no per-level mirroring needed. +fn extract_to(dest: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dest)?; + write_dir(&RENDER_DIR, dest) +} + +/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the +/// full relative path of each file. +fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> { + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?, + include_dir::DirEntry::File(file) => { + let file_dest = dest.join(file.path()); + if let Some(parent) = file_dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file_dest, file.contents())?; + } + } + } + Ok(()) +} diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs new file mode 100644 index 0000000..e1bf25f --- /dev/null +++ b/crates/cph-typst/src/lib.rs @@ -0,0 +1,251 @@ +//! `cph-typst` — typst `World`, augmented-manifest injection, compile, PDF, +//! span mapping. +//! +//! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to: +//! - **compile-check** a [`cph_model::Lesson`] for a target (collect typst +//! errors+warnings as [`cph_diag::Diagnostic`]s), and +//! - **export a PDF** for a render target. +//! +//! ## Model (ADR-0011): compile a template, inject a manifest +//! +//! There is **no framework-generated driver** any more. An export target is a +//! build with a typed [`cph_model::Step::TypstCompile`] naming a **template +//! file** (e.g. `exports/student.typ`) that lives in the engineering file. The +//! engine: +//! +//! 1. mounts the lesson's engineering-file tree (ADR-0007) as the typst project +//! root (`--root`); +//! 2. sets the World's `main` to that **real template file** under the root, so +//! diagnostic spans resolve to an authored file; +//! 3. builds an **augmented manifest** (the lesson's `info` + ordered `parts`, +//! each with a `fields` array of the content fields present on disk — see +//! [`manifest`]) and serves it as a virtual in-memory file at +//! [`world::MANIFEST_VPATH`], injecting `sys.inputs.manifest` to point there; +//! 4. lets the template `toml()`-read that manifest and `include` each part's +//! content via computed root-relative paths (legal per ADR-0011). +//! +//! The augmented manifest closes the OPEN optional-content point: typst has no +//! file-exists primitive, so the engine (which has filesystem access) tells the +//! template which optional content fields exist via the per-part `fields` array. +//! +//! ## typst version +//! +//! Pinned to the `typst` family `=0.15.0`. The 0.15 path/file API differs from +//! 0.14: `FileId::new(RootedPath)` where `RootedPath::new(VirtualRoot, +//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`). + +mod diag; +mod embedded; +mod manifest; +mod world; + +use std::path::PathBuf; + +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::{Artifact, Lesson, Step, TargetConfig}; +use typst_kit::fonts::{self, FontStore}; +use typst_layout::PagedDocument; +use typst_pdf::PdfOptions; + +pub use manifest::build_augmented_manifest; +pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; + +/// The compile/PDF engine: holds the shared font store and the on-disk location +/// of the `cph-render` package. +/// +/// Fonts are discovered once (system + embedded) and shared across every +/// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`]. +/// +/// `render_dir` is the directory the World resolves `@local/cph-render` (and the +/// vendored `@preview/*`) from. It is **not** where the compiled template comes +/// from: the template is a real file under the lesson root (`/