From ebf841c7d905b74734f5f635d9e517beb8d42221 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Tue, 21 Apr 2026 21:07:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(primitives):=203=20Rust=20cubes=20?= =?UTF-8?q?=E2=80=94=20mock-render,=20visual-diff,=20tokens-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _primitives/_rust/Cargo.lock | 1089 +++++++++++++++++++ _primitives/_rust/mock-render/Cargo.toml | 17 + _primitives/_rust/mock-render/src/hash.rs | 48 + _primitives/_rust/mock-render/src/main.rs | 226 ++++ _primitives/_rust/mock-render/src/render.rs | 49 + _primitives/_rust/mock-render/src/state.rs | 88 ++ _primitives/_rust/tokens-sync/Cargo.toml | 16 + _primitives/_rust/tokens-sync/src/emit.rs | 127 +++ _primitives/_rust/tokens-sync/src/main.rs | 85 ++ _primitives/_rust/tokens-sync/src/parse.rs | 71 ++ _primitives/_rust/visual-diff/Cargo.toml | 15 + _primitives/_rust/visual-diff/src/diff.rs | 136 +++ _primitives/_rust/visual-diff/src/main.rs | 83 ++ 13 files changed, 2050 insertions(+) create mode 100644 _primitives/_rust/Cargo.lock create mode 100644 _primitives/_rust/mock-render/Cargo.toml create mode 100644 _primitives/_rust/mock-render/src/hash.rs create mode 100644 _primitives/_rust/mock-render/src/main.rs create mode 100644 _primitives/_rust/mock-render/src/render.rs create mode 100644 _primitives/_rust/mock-render/src/state.rs create mode 100644 _primitives/_rust/tokens-sync/Cargo.toml create mode 100644 _primitives/_rust/tokens-sync/src/emit.rs create mode 100644 _primitives/_rust/tokens-sync/src/main.rs create mode 100644 _primitives/_rust/tokens-sync/src/parse.rs create mode 100644 _primitives/_rust/visual-diff/Cargo.toml create mode 100644 _primitives/_rust/visual-diff/src/diff.rs create mode 100644 _primitives/_rust/visual-diff/src/main.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock new file mode 100644 index 0000000..4e82a0e --- /dev/null +++ b/_primitives/_rust/Cargo.lock @@ -0,0 +1,1089 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[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 = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[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.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[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 = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[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 = "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", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[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 = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kei-ledger" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "mock-render" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[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 = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tokens-sync" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visual-diff" +version = "0.1.0" +dependencies = [ + "image", + "tempfile", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/_primitives/_rust/mock-render/Cargo.toml b/_primitives/_rust/mock-render/Cargo.toml new file mode 100644 index 0000000..344d49c --- /dev/null +++ b/_primitives/_rust/mock-render/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mock-render" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "mock-render" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/mock-render/src/hash.rs b/_primitives/_rust/mock-render/src/hash.rs new file mode 100644 index 0000000..a80001d --- /dev/null +++ b/_primitives/_rust/mock-render/src/hash.rs @@ -0,0 +1,48 @@ +//! SHA-256 helpers. Used for WYSIWYD invariant checks +//! (source file must not mutate between lock and verify). + +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; + +pub fn hash_file(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn hashes_empty_file() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(b"").unwrap(); + let h = hash_file(f.path()).unwrap(); + // sha256("") — well-known constant + assert_eq!( + h, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn hashes_hello_world() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(b"hello world").unwrap(); + let h = hash_file(f.path()).unwrap(); + assert_eq!( + h, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn returns_err_on_missing_file() { + let result = hash_file(Path::new("/nonexistent/path/xyz")); + assert!(result.is_err()); + } +} diff --git a/_primitives/_rust/mock-render/src/main.rs b/_primitives/_rust/mock-render/src/main.rs new file mode 100644 index 0000000..ed9a091 --- /dev/null +++ b/_primitives/_rust/mock-render/src/main.rs @@ -0,0 +1,226 @@ +//! mock-render — enforces the WYSIWYD invariant (What You See Is What's Deployed) +//! for block-based site-builder. Every section = one source file; screenshot is +//! a render of that file; lock freezes the hash; verify fails if source mutated. +//! +//! USAGE +//! mock-render screenshot --out [--viewport WxH] +//! mock-render lock --project --section [--screenshot ] +//! mock-render verify --project --section +//! mock-render status --project + +mod hash; +mod render; +mod state; + +use state::{Section, SiteState}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("screenshot") => cmd_screenshot(&args[1..]), + Some("lock") => cmd_lock(&args[1..]), + Some("verify") => cmd_verify(&args[1..]), + Some("status") => cmd_status(&args[1..]), + Some("--help") | Some("-h") | None => { + print_help(); + ExitCode::SUCCESS + } + Some(cmd) => { + eprintln!("mock-render: unknown command '{cmd}'. Run with --help."); + ExitCode::from(1) + } + } +} + +fn print_help() { + println!( + "mock-render — WYSIWYD invariant enforcer for site-builder + +USAGE + mock-render screenshot --out [--viewport WxH] + mock-render lock --project --section [--screenshot ] + mock-render verify --project --section + mock-render status --project + +EXIT + 0 ok + 1 usage / missing args + 2 WYSIWYD invariant violated (file drift / hash mismatch)" + ); +} + +fn cmd_screenshot(args: &[String]) -> ExitCode { + let Some(url) = args.first().cloned() else { + eprintln!("screenshot: required"); + return ExitCode::from(1); + }; + let out = match flag(args, "--out") { + Some(p) => PathBuf::from(p), + None => { + eprintln!("screenshot: --out required"); + return ExitCode::from(1); + } + }; + let viewport = flag(args, "--viewport").and_then(parse_viewport); + + match render::screenshot(&url, &out, viewport) { + Ok(()) => { + println!("{}", out.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("mock-render: {e}"); + ExitCode::from(1) + } + } +} + +fn cmd_lock(args: &[String]) -> ExitCode { + let (project, section) = match require_project_section(args) { + Ok(v) => v, + Err(e) => { + eprintln!("lock: {e}"); + return ExitCode::from(1); + } + }; + let screenshot = flag(args, "--screenshot"); + + let Ok(hash_now) = hash::hash_file(§ion) else { + eprintln!("lock: cannot hash {}", section.display()); + return ExitCode::from(2); + }; + + let mut st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("lock: {e}"); + return ExitCode::from(2); + } + }; + + let key = SiteState::key_for(§ion); + st.sections.insert( + key.clone(), + Section { + path: section.display().to_string(), + sha256: hash_now.clone(), + locked: true, + screenshot: screenshot.map(String::from), + }, + ); + + if let Err(e) = st.save(&project) { + eprintln!("lock: {e}"); + return ExitCode::from(2); + } + + println!("locked {key} ({})", &hash_now[..12]); + ExitCode::SUCCESS +} + +fn cmd_verify(args: &[String]) -> ExitCode { + let (project, section) = match require_project_section(args) { + Ok(v) => v, + Err(e) => { + eprintln!("verify: {e}"); + return ExitCode::from(1); + } + }; + + let st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("verify: {e}"); + return ExitCode::from(2); + } + }; + + let key = SiteState::key_for(§ion); + let Some(entry) = st.sections.get(&key) else { + eprintln!("verify: section '{key}' not in site-state.json (not locked yet)"); + return ExitCode::SUCCESS; + }; + if !entry.locked { + return ExitCode::SUCCESS; + } + + let Ok(hash_now) = hash::hash_file(§ion) else { + eprintln!("verify: cannot hash {}", section.display()); + return ExitCode::from(2); + }; + + if hash_now != entry.sha256 { + eprintln!( + "WYSIWYD VIOLATION: {key} drifted\n locked : {}\n current: {}\nThe screenshot user approved no longer matches the source.\nRerun render + user-approval before deploy.", + &entry.sha256[..12], + &hash_now[..12] + ); + return ExitCode::from(2); + } + println!("ok {key} ({})", &hash_now[..12]); + ExitCode::SUCCESS +} + +fn cmd_status(args: &[String]) -> ExitCode { + let project = flag(args, "--project") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + let st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("status: {e}"); + return ExitCode::from(2); + } + }; + + if st.sections.is_empty() { + println!("(no sections tracked)"); + return ExitCode::SUCCESS; + } + + for (name, sec) in &st.sections { + let lock = if sec.locked { "LOCKED" } else { "open" }; + let drift = match hash::hash_file(Path::new(&sec.path)) { + Ok(h) if h == sec.sha256 => "clean", + Ok(_) => "DRIFT", + Err(_) => "missing", + }; + println!( + "{:<20} {:>6} {:<7} {} ({})", + name, + lock, + drift, + sec.path, + &sec.sha256[..12] + ); + } + ExitCode::SUCCESS +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2) + .find(|w| w[0] == name) + .map(|w| w[1].as_str()) +} + +fn parse_viewport(s: &str) -> Option<(u32, u32)> { + let (w, h) = s.split_once('x')?; + Some((w.parse().ok()?, h.parse().ok()?)) +} + +fn require_project_section(args: &[String]) -> Result<(PathBuf, PathBuf), String> { + let project = flag(args, "--project") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let section = flag(args, "--section") + .map(PathBuf::from) + .ok_or_else(|| "--section required".to_string())?; + if !section.exists() { + return Err(format!("section file not found: {}", section.display())); + } + Ok((project, section)) +} diff --git a/_primitives/_rust/mock-render/src/render.rs b/_primitives/_rust/mock-render/src/render.rs new file mode 100644 index 0000000..68e1bfb --- /dev/null +++ b/_primitives/_rust/mock-render/src/render.rs @@ -0,0 +1,49 @@ +//! Playwright subprocess wrapper (RULE 0.2 exception 6 — JS-only binding). +//! Calls `npx playwright screenshot` with clear error messages. +//! +//! Requires Node + `npx`. Playwright browsers installable via: +//! npx playwright install chromium + +use std::path::Path; +use std::process::Command; + +/// Render a URL (typically http://localhost:/) or a file:// URL +/// to a PNG via Playwright's CLI. +pub fn screenshot(url: &str, out: &Path, viewport: Option<(u32, u32)>) -> Result<(), String> { + let mut args = vec![ + "--yes".to_string(), + "playwright".to_string(), + "screenshot".to_string(), + "--full-page".to_string(), + ]; + + if let Some((w, h)) = viewport { + args.push("--viewport-size".to_string()); + args.push(format!("{w},{h}")); + } + + args.push(url.to_string()); + args.push(out.display().to_string()); + + let output = Command::new("npx") + .args(&args) + .output() + .map_err(|e| format!("npx spawn: {e} — is Node installed?"))?; + + if !output.status.success() { + return Err(format!( + "playwright screenshot failed (exit {}):\n{}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stderr) + )); + } + + if !out.exists() { + return Err(format!( + "playwright claimed success but {} was not written", + out.display() + )); + } + + Ok(()) +} diff --git a/_primitives/_rust/mock-render/src/state.rs b/_primitives/_rust/mock-render/src/state.rs new file mode 100644 index 0000000..d09bf11 --- /dev/null +++ b/_primitives/_rust/mock-render/src/state.rs @@ -0,0 +1,88 @@ +//! site-state.json — single file that tracks which sections are locked +//! and what their approved SHA-256 is. One row per section. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_STATE: &str = "site-state.json"; + +#[derive(Serialize, Deserialize, Default)] +pub struct SiteState { + pub sections: BTreeMap, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Section { + pub path: String, + pub sha256: String, + pub locked: bool, + pub screenshot: Option, +} + +impl SiteState { + pub fn load(project: &Path) -> Result { + let p = Self::path(project); + if !p.exists() { + return Ok(Self::default()); + } + let text = fs::read_to_string(&p).map_err(|e| format!("read state: {e}"))?; + serde_json::from_str(&text).map_err(|e| format!("parse state: {e}")) + } + + pub fn save(&self, project: &Path) -> Result<(), String> { + let p = Self::path(project); + let text = serde_json::to_string_pretty(self).map_err(|e| format!("serialize: {e}"))?; + fs::write(&p, text).map_err(|e| format!("write state: {e}")) + } + + fn path(project: &Path) -> PathBuf { + project.join(DEFAULT_STATE) + } + + pub fn key_for(section_path: &Path) -> String { + section_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_uses_file_stem() { + let k = SiteState::key_for(Path::new("src/sections/Hero.astro")); + assert_eq!(k, "Hero"); + } + + #[test] + fn load_missing_returns_default() { + let tmp = tempfile::tempdir().unwrap(); + let st = SiteState::load(tmp.path()).unwrap(); + assert!(st.sections.is_empty()); + } + + #[test] + fn save_and_reload_roundtrips() { + let tmp = tempfile::tempdir().unwrap(); + let mut st = SiteState::default(); + st.sections.insert( + "Hero".into(), + Section { + path: "src/sections/Hero.astro".into(), + sha256: "abcdef".repeat(10), + locked: true, + screenshot: Some("mocks/Hero.png".into()), + }, + ); + st.save(tmp.path()).unwrap(); + let reloaded = SiteState::load(tmp.path()).unwrap(); + assert_eq!(reloaded.sections.len(), 1); + assert!(reloaded.sections.get("Hero").unwrap().locked); + } +} diff --git a/_primitives/_rust/tokens-sync/Cargo.toml b/_primitives/_rust/tokens-sync/Cargo.toml new file mode 100644 index 0000000..e726c30 --- /dev/null +++ b/_primitives/_rust/tokens-sync/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tokens-sync" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "tokens-sync" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/tokens-sync/src/emit.rs b/_primitives/_rust/tokens-sync/src/emit.rs new file mode 100644 index 0000000..0d4af42 --- /dev/null +++ b/_primitives/_rust/tokens-sync/src/emit.rs @@ -0,0 +1,127 @@ +//! Tokens → Tailwind config (TypeScript) + CSS custom-property emission. +//! Emits stable, deterministically-ordered output (BTreeMap input guarantees). + +use crate::parse::Tokens; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +pub fn tailwind_config(t: &Tokens, out: &Path) -> Result<(), String> { + let mut s = String::new(); + s.push_str("// GENERATED by tokens-sync — do not edit by hand.\n"); + s.push_str("import type { Config } from 'tailwindcss';\n\n"); + s.push_str("const config: Config = {\n"); + s.push_str(" content: [],\n"); + s.push_str(" theme: {\n extend: {\n"); + + emit_category(&mut s, "colors", &t.colors); + emit_category(&mut s, "fontFamily", &t.fonts); + emit_category(&mut s, "spacing", &t.spacing); + emit_category(&mut s, "borderRadius", &t.radius); + + s.push_str(" },\n },\n plugins: [],\n};\n\nexport default config;\n"); + + fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display())) +} + +pub fn css_vars(t: &Tokens, out: &Path) -> Result<(), String> { + let mut s = String::new(); + s.push_str("/* GENERATED by tokens-sync — do not edit by hand. */\n:root {\n"); + + for (k, v) in &t.colors { + let _ = writeln!(s, " --color-{}: {};", css_ident(k), v); + } + for (k, v) in &t.fonts { + let _ = writeln!(s, " --font-{}: {};", css_ident(k), v); + } + for (k, v) in &t.spacing { + let _ = writeln!(s, " --space-{}: {};", css_ident(k), v); + } + for (k, v) in &t.radius { + let _ = writeln!(s, " --radius-{}: {};", css_ident(k), v); + } + + s.push_str("}\n"); + + fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display())) +} + +fn emit_category( + s: &mut String, + key: &str, + map: &std::collections::BTreeMap, +) { + if map.is_empty() { + return; + } + let _ = writeln!(s, " {key}: {{"); + for (k, v) in map { + // Escape JS single quotes inside the value string. + let escaped = v.replace('\'', "\\'"); + let _ = writeln!(s, " '{k}': '{escaped}',"); + } + s.push_str(" },\n"); +} + +fn css_ident(raw: &str) -> String { + raw.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn sample_tokens() -> Tokens { + let mut colors = BTreeMap::new(); + colors.insert("primary".into(), "oklch(0.6 0.2 250)".into()); + colors.insert("surface".into(), "oklch(0.99 0 0)".into()); + let mut fonts = BTreeMap::new(); + fonts.insert("body".into(), "Inter, sans-serif".into()); + let mut spacing = BTreeMap::new(); + spacing.insert("md".into(), "1rem".into()); + let mut radius = BTreeMap::new(); + radius.insert("card".into(), "0.75rem".into()); + Tokens { colors, fonts, spacing, radius } + } + + #[test] + fn tailwind_emits_extend_categories() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("tw.ts"); + tailwind_config(&sample_tokens(), &out).unwrap(); + let text = fs::read_to_string(&out).unwrap(); + assert!(text.contains("colors: {")); + assert!(text.contains("'primary': 'oklch(0.6 0.2 250)'")); + assert!(text.contains("fontFamily: {")); + assert!(text.contains("borderRadius: {")); + } + + #[test] + fn css_emits_vars_under_root() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("tokens.css"); + css_vars(&sample_tokens(), &out).unwrap(); + let text = fs::read_to_string(&out).unwrap(); + assert!(text.contains(":root {")); + assert!(text.contains("--color-primary: oklch(0.6 0.2 250);")); + assert!(text.contains("--font-body: Inter, sans-serif;")); + assert!(text.contains("--space-md: 1rem;")); + assert!(text.contains("--radius-card: 0.75rem;")); + } + + #[test] + fn empty_tokens_still_emit_valid_shells() { + let dir = tempfile::tempdir().unwrap(); + let t = Tokens::default(); + let tw = dir.path().join("tw.ts"); + let css = dir.path().join("c.css"); + tailwind_config(&t, &tw).unwrap(); + css_vars(&t, &css).unwrap(); + let css_text = fs::read_to_string(&css).unwrap(); + assert!(css_text.contains(":root {")); + assert!(css_text.trim_end().ends_with('}')); + } +} diff --git a/_primitives/_rust/tokens-sync/src/main.rs b/_primitives/_rust/tokens-sync/src/main.rs new file mode 100644 index 0000000..285cea9 --- /dev/null +++ b/_primitives/_rust/tokens-sync/src/main.rs @@ -0,0 +1,85 @@ +//! tokens-sync — emit Tailwind config + CSS custom properties from a single +//! design-tokens JSON file. One SSoT; no drift between CSS/JS sides. +//! +//! USAGE +//! tokens-sync --out-tailwind --out-css +//! +//! Input JSON shape (minimum): +//! { +//! "colors": { "primary": "oklch(0.6 0.2 250)", ... }, +//! "fonts": { "display": "Fraunces Variable, serif", ... }, +//! "spacing": { "sm": "0.5rem", ... }, +//! "radius": { "card": "0.75rem", ... } +//! } +//! +//! At least one of --out-tailwind or --out-css must be supplied. + +mod emit; +mod parse; + +use std::env; +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + + if args.iter().any(|a| a == "-h" || a == "--help") { + print_help(); + return ExitCode::SUCCESS; + } + + let Some(input) = args.iter().find(|a| !a.starts_with("--")).cloned() else { + eprintln!("tokens-sync: required"); + print_help(); + return ExitCode::from(1); + }; + let tailwind = flag(&args, "--out-tailwind").map(PathBuf::from); + let css = flag(&args, "--out-css").map(PathBuf::from); + + if tailwind.is_none() && css.is_none() { + eprintln!("tokens-sync: need at least one of --out-tailwind or --out-css"); + return ExitCode::from(1); + } + + let tokens = match parse::load(&PathBuf::from(&input)) { + Ok(t) => t, + Err(e) => { + eprintln!("tokens-sync: {e}"); + return ExitCode::from(1); + } + }; + + if let Some(path) = tailwind { + if let Err(e) = emit::tailwind_config(&tokens, &path) { + eprintln!("tokens-sync: emit tailwind: {e}"); + return ExitCode::from(1); + } + println!("tailwind -> {}", path.display()); + } + if let Some(path) = css { + if let Err(e) = emit::css_vars(&tokens, &path) { + eprintln!("tokens-sync: emit css: {e}"); + return ExitCode::from(1); + } + println!("css -> {}", path.display()); + } + + ExitCode::SUCCESS +} + +fn print_help() { + println!( + "tokens-sync — design tokens JSON → Tailwind config + CSS vars + +USAGE + tokens-sync --out-tailwind --out-css + +Write at least one of --out-tailwind or --out-css (both allowed). +JSON schema: see source for minimum shape." + ); +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str()) +} diff --git a/_primitives/_rust/tokens-sync/src/parse.rs b/_primitives/_rust/tokens-sync/src/parse.rs new file mode 100644 index 0000000..0e05e14 --- /dev/null +++ b/_primitives/_rust/tokens-sync/src/parse.rs @@ -0,0 +1,71 @@ +//! JSON → Tokens model. Flat string-keyed maps per category (colors, fonts, +//! spacing, radius). Unknown categories are ignored; missing categories +//! default to empty. + +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +#[derive(Deserialize, Default)] +pub struct Tokens { + #[serde(default)] + pub colors: BTreeMap, + #[serde(default)] + pub fonts: BTreeMap, + #[serde(default)] + pub spacing: BTreeMap, + #[serde(default)] + pub radius: BTreeMap, +} + +pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn parses_full_shape() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!( + f, + r#"{{ + "colors": {{ "primary": "oklch(0.6 0.2 250)", "surface": "oklch(0.995 0 0)" }}, + "fonts": {{ "display": "Fraunces, serif", "body": "Inter, sans-serif" }}, + "spacing": {{ "sm": "0.5rem", "md": "1rem" }}, + "radius": {{ "card": "0.75rem" }} + }}"# + ) + .unwrap(); + let tokens = load(f.path()).unwrap(); + assert_eq!(tokens.colors.len(), 2); + assert_eq!(tokens.colors.get("primary").unwrap(), "oklch(0.6 0.2 250)"); + assert_eq!(tokens.fonts.get("body").unwrap(), "Inter, sans-serif"); + assert_eq!(tokens.spacing.len(), 2); + assert_eq!(tokens.radius.len(), 1); + } + + #[test] + fn missing_categories_default_empty() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + let payload = r##"{ "colors": { "primary": "#000" } }"##; + f.write_all(payload.as_bytes()).unwrap(); + let tokens = load(f.path()).unwrap(); + assert_eq!(tokens.colors.len(), 1); + assert!(tokens.fonts.is_empty()); + assert!(tokens.spacing.is_empty()); + assert!(tokens.radius.is_empty()); + } + + #[test] + fn invalid_json_errors() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!(f, "not json").unwrap(); + assert!(load(f.path()).is_err()); + } +} diff --git a/_primitives/_rust/visual-diff/Cargo.toml b/_primitives/_rust/visual-diff/Cargo.toml new file mode 100644 index 0000000..f6c92fa --- /dev/null +++ b/_primitives/_rust/visual-diff/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "visual-diff" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "visual-diff" +path = "src/main.rs" + +[dependencies] +image = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/visual-diff/src/diff.rs b/_primitives/_rust/visual-diff/src/diff.rs new file mode 100644 index 0000000..c3ef167 --- /dev/null +++ b/_primitives/_rust/visual-diff/src/diff.rs @@ -0,0 +1,136 @@ +//! PNG diff routine. Decodes both images, compares pixels with a per-channel +//! tolerance, writes a red-tinted overlay where pixels differ, and reports +//! mismatch percentage. + +use image::{ImageBuffer, Rgba, RgbaImage}; +use std::path::Path; + +pub struct Report { + pub pct: f64, + pub diff_px: u64, + pub total_px: u64, + pub diff_png_written: bool, +} + +const CHANNEL_TOLERANCE: u8 = 4; + +pub fn compare(a: &Path, b: &Path, out: &Path) -> Result { + let img_a = image::open(a).map_err(|e| format!("open {}: {e}", a.display()))?.to_rgba8(); + let img_b = image::open(b).map_err(|e| format!("open {}: {e}", b.display()))?.to_rgba8(); + + let (wa, ha) = img_a.dimensions(); + let (wb, hb) = img_b.dimensions(); + + if (wa, ha) != (wb, hb) { + return Err(format!( + "dimension mismatch: a={wa}x{ha} b={wb}x{hb} (resize before comparing)" + )); + } + + let total_px = u64::from(wa) * u64::from(ha); + let mut diff_px: u64 = 0; + let mut overlay: RgbaImage = ImageBuffer::new(wa, ha); + + for y in 0..ha { + for x in 0..wa { + let pa = img_a.get_pixel(x, y); + let pb = img_b.get_pixel(x, y); + if pixel_differs(pa, pb, CHANNEL_TOLERANCE) { + diff_px += 1; + overlay.put_pixel(x, y, Rgba([255, 0, 64, 200])); // red flag + } else { + // faded original to give context + let faded = Rgba([pa[0] / 3, pa[1] / 3, pa[2] / 3, 255]); + overlay.put_pixel(x, y, faded); + } + } + } + + let pct = if total_px == 0 { + 0.0 + } else { + (diff_px as f64 / total_px as f64) * 100.0 + }; + + let mut diff_png_written = false; + if diff_px > 0 { + overlay + .save(out) + .map_err(|e| format!("write {}: {e}", out.display()))?; + diff_png_written = true; + } + + Ok(Report { + pct, + diff_px, + total_px, + diff_png_written, + }) +} + +fn pixel_differs(a: &Rgba, b: &Rgba, tol: u8) -> bool { + (0..4).any(|i| a[i].abs_diff(b[i]) > tol) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::ImageBuffer; + + fn write_solid(path: &Path, w: u32, h: u32, color: [u8; 4]) { + let img: RgbaImage = ImageBuffer::from_pixel(w, h, Rgba(color)); + img.save(path).unwrap(); + } + + #[test] + fn identical_images_match() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.png"); + let b = dir.path().join("b.png"); + let out = dir.path().join("diff.png"); + write_solid(&a, 16, 16, [255, 255, 255, 255]); + write_solid(&b, 16, 16, [255, 255, 255, 255]); + let r = compare(&a, &b, &out).unwrap(); + assert_eq!(r.diff_px, 0); + assert_eq!(r.total_px, 256); + assert!(!r.diff_png_written); + } + + #[test] + fn fully_different_images() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.png"); + let b = dir.path().join("b.png"); + let out = dir.path().join("diff.png"); + write_solid(&a, 8, 8, [0, 0, 0, 255]); + write_solid(&b, 8, 8, [255, 255, 255, 255]); + let r = compare(&a, &b, &out).unwrap(); + assert_eq!(r.diff_px, 64); + assert_eq!(r.total_px, 64); + assert!((r.pct - 100.0).abs() < 1e-6); + assert!(r.diff_png_written); + } + + #[test] + fn dimension_mismatch_errors() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.png"); + let b = dir.path().join("b.png"); + let out = dir.path().join("diff.png"); + write_solid(&a, 8, 8, [0, 0, 0, 255]); + write_solid(&b, 16, 16, [0, 0, 0, 255]); + assert!(compare(&a, &b, &out).is_err()); + } + + #[test] + fn tolerance_absorbs_tiny_delta() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.png"); + let b = dir.path().join("b.png"); + let out = dir.path().join("diff.png"); + write_solid(&a, 8, 8, [100, 100, 100, 255]); + write_solid(&b, 8, 8, [103, 103, 103, 255]); // within CHANNEL_TOLERANCE=4 + let r = compare(&a, &b, &out).unwrap(); + assert_eq!(r.diff_px, 0); + } +} diff --git a/_primitives/_rust/visual-diff/src/main.rs b/_primitives/_rust/visual-diff/src/main.rs new file mode 100644 index 0000000..054a1bb --- /dev/null +++ b/_primitives/_rust/visual-diff/src/main.rs @@ -0,0 +1,83 @@ +//! visual-diff — pixel-level PNG comparator for WYSIWYD drift detection. +//! +//! USAGE +//! visual-diff [--out diff.png] [--threshold 5] +//! +//! Exit codes: +//! 0 images equal (within threshold) +//! 1 usage error +//! 2 images differ beyond threshold +//! +//! Prints percentage of mismatched pixels to stdout. Writes a red-overlay +//! diff PNG to (default: ./diff.png) when images differ. + +mod diff; + +use std::env; +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + + if args.iter().any(|a| a == "-h" || a == "--help") { + print_help(); + return ExitCode::SUCCESS; + } + + let positional: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); + if positional.len() < 2 { + eprintln!("visual-diff: need "); + print_help(); + return ExitCode::from(1); + } + + let a = PathBuf::from(positional[0]); + let b = PathBuf::from(positional[1]); + let out = flag(&args, "--out") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("diff.png")); + let threshold: f64 = flag(&args, "--threshold") + .and_then(|s| s.parse().ok()) + .unwrap_or(1.0); + + match diff::compare(&a, &b, &out) { + Ok(report) => { + println!("{:.4}% differ ({} px of {})", report.pct, report.diff_px, report.total_px); + if report.diff_png_written { + eprintln!("wrote diff: {}", out.display()); + } + if report.pct > threshold { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + } + } + Err(e) => { + eprintln!("visual-diff: {e}"); + ExitCode::from(1) + } + } +} + +fn print_help() { + println!( + "visual-diff — pixel-level PNG comparator + +USAGE + visual-diff [--out diff.png] [--threshold 5] + +OPTIONS + --out FILE write red-overlay diff PNG (default: diff.png) + --threshold PCT fail (exit 2) if mismatch exceeds PCT%% (default: 1.0) + +EXIT + 0 equal (within threshold) + 1 usage / IO error + 2 differ beyond threshold" + ); +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2).find(|w| w[0] == name).map(|w| w[1].as_str()) +}