From 9f0da76ca2002bf7d6ba739064c116da7678f52b Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sun, 3 May 2026 04:05:04 -0500 Subject: [PATCH] =?UTF-8?q?remove=20rust=20scaffolding=20=E2=80=94=20el=20?= =?UTF-8?q?is=20the=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 897 --------- Cargo.toml | 17 - examples/profile-card/Cargo.toml | 16 - examples/profile-card/src/main.rs | 350 ---- vessels/el-aop/Cargo.toml | 15 - vessels/el-aop/src/aspects.rs | 490 ----- vessels/el-aop/src/chain.rs | 122 -- vessels/el-aop/src/lib.rs | 157 -- vessels/el-aop/src/public.rs | 57 - vessels/el-aop/src/registry.rs | 284 --- vessels/el-aop/src/tests.rs | 305 --- vessels/el-auth/Cargo.toml | 20 - vessels/el-auth/src/context.rs | 78 - vessels/el-auth/src/engram_session.rs | 128 -- vessels/el-auth/src/jwt.rs | 314 ---- vessels/el-auth/src/lib.rs | 68 - vessels/el-auth/src/middleware.rs | 59 - vessels/el-auth/src/roles.rs | 90 - vessels/el-auth/src/session.rs | 105 -- vessels/el-auth/src/tests.rs | 252 --- vessels/el-config/Cargo.toml | 18 - vessels/el-config/src/config.rs | 337 ---- vessels/el-config/src/env.rs | 114 -- vessels/el-config/src/error.rs | 20 - vessels/el-config/src/lib.rs | 42 - vessels/el-config/src/source.rs | 248 --- vessels/el-i18n/Cargo.toml | 18 - vessels/el-i18n/src/bundle.rs | 299 --- vessels/el-i18n/src/format.rs | 221 --- vessels/el-i18n/src/lib.rs | 44 - vessels/el-i18n/src/locale.rs | 182 -- vessels/el-i18n/src/plural.rs | 189 -- vessels/el-i18n/src/t.rs | 209 --- vessels/el-identity/Cargo.toml | 22 - vessels/el-identity/src/context.rs | 96 - vessels/el-identity/src/engram.rs | 214 --- vessels/el-identity/src/error.rs | 56 - vessels/el-identity/src/guard.rs | 152 -- vessels/el-identity/src/lib.rs | 37 - vessels/el-identity/src/nodes.rs | 244 --- vessels/el-identity/src/oauth.rs | 275 --- vessels/el-identity/src/provider.rs | 372 ---- vessels/el-identity/src/session.rs | 120 -- vessels/el-identity/src/tests.rs | 563 ------ vessels/el-layout/Cargo.toml | 16 - vessels/el-layout/src/breakpoint.rs | 98 - vessels/el-layout/src/constraints.rs | 162 -- vessels/el-layout/src/flex.rs | 178 -- vessels/el-layout/src/grid.rs | 187 -- vessels/el-layout/src/lib.rs | 50 - vessels/el-layout/src/platform.rs | 197 -- vessels/el-layout/src/responsive.rs | 154 -- vessels/el-layout/src/scroll.rs | 141 -- vessels/el-layout/src/stack.rs | 286 --- vessels/el-platform/Cargo.toml | 15 - vessels/el-platform/src/backends/android.rs | 169 -- vessels/el-platform/src/backends/ios.rs | 166 -- vessels/el-platform/src/backends/linux.rs | 155 -- vessels/el-platform/src/backends/macos.rs | 137 -- vessels/el-platform/src/backends/mod.rs | 12 - vessels/el-platform/src/backends/server.rs | 160 -- vessels/el-platform/src/backends/tests.rs | 253 --- vessels/el-platform/src/backends/web.rs | 134 -- vessels/el-platform/src/backends/windows.rs | 156 -- vessels/el-platform/src/config.rs | 82 - vessels/el-platform/src/lib.rs | 126 -- vessels/el-platform/src/node.rs | 158 -- vessels/el-publish/Cargo.toml | 15 - vessels/el-publish/src/apple.rs | 126 -- vessels/el-publish/src/cert.rs | 157 -- vessels/el-publish/src/config.rs | 144 -- vessels/el-publish/src/google.rs | 124 -- vessels/el-publish/src/lib.rs | 98 - vessels/el-publish/src/metadata.rs | 122 -- vessels/el-publish/src/rollout.rs | 131 -- vessels/el-publish/src/screenshot.rs | 135 -- vessels/el-publish/src/tests.rs | 264 --- vessels/el-secrets/Cargo.toml | 17 - vessels/el-secrets/src/error.rs | 19 - vessels/el-secrets/src/lib.rs | 51 - vessels/el-secrets/src/resolver.rs | 282 --- vessels/el-secrets/src/secret.rs | 179 -- vessels/el-secrets/src/source.rs | 251 --- vessels/el-services/Cargo.toml | 15 - vessels/el-services/src/binding/direct.rs | 84 - vessels/el-services/src/binding/grpc.rs | 97 - vessels/el-services/src/binding/mod.rs | 114 -- vessels/el-services/src/binding/rest.rs | 147 -- vessels/el-services/src/binding/websocket.rs | 85 - vessels/el-services/src/config.rs | 108 -- vessels/el-services/src/lib.rs | 51 - vessels/el-services/src/proxy.rs | 113 -- vessels/el-services/src/registry.rs | 59 - vessels/el-services/src/tests.rs | 261 --- vessels/el-style/Cargo.toml | 17 - vessels/el-style/src/color.rs | 255 --- vessels/el-style/src/lib.rs | 51 - vessels/el-style/src/modifier.rs | 382 ---- vessels/el-style/src/radius.rs | 116 -- vessels/el-style/src/shadow.rs | 190 -- vessels/el-style/src/spacing.rs | 137 -- vessels/el-style/src/stylesheet.rs | 160 -- vessels/el-style/src/theme.rs | 144 -- vessels/el-style/src/typography.rs | 235 --- vessels/el-ui-compiler/Cargo.toml | 19 - vessels/el-ui-compiler/src/ast.rs | 93 - vessels/el-ui-compiler/src/codegen.rs | 1733 ------------------ vessels/el-ui-compiler/src/error.rs | 15 - vessels/el-ui-compiler/src/lexer.rs | 568 ------ vessels/el-ui-compiler/src/lib.rs | 78 - vessels/el-ui-compiler/src/main.rs | 46 - vessels/el-ui-compiler/src/parser.rs | 640 ------- vessels/el-ui-compiler/src/semantic.rs | 523 ------ vessels/el-ui-compiler/src/tests.rs | 480 ----- 114 files changed, 20239 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 examples/profile-card/Cargo.toml delete mode 100644 examples/profile-card/src/main.rs delete mode 100644 vessels/el-aop/Cargo.toml delete mode 100644 vessels/el-aop/src/aspects.rs delete mode 100644 vessels/el-aop/src/chain.rs delete mode 100644 vessels/el-aop/src/lib.rs delete mode 100644 vessels/el-aop/src/public.rs delete mode 100644 vessels/el-aop/src/registry.rs delete mode 100644 vessels/el-aop/src/tests.rs delete mode 100644 vessels/el-auth/Cargo.toml delete mode 100644 vessels/el-auth/src/context.rs delete mode 100644 vessels/el-auth/src/engram_session.rs delete mode 100644 vessels/el-auth/src/jwt.rs delete mode 100644 vessels/el-auth/src/lib.rs delete mode 100644 vessels/el-auth/src/middleware.rs delete mode 100644 vessels/el-auth/src/roles.rs delete mode 100644 vessels/el-auth/src/session.rs delete mode 100644 vessels/el-auth/src/tests.rs delete mode 100644 vessels/el-config/Cargo.toml delete mode 100644 vessels/el-config/src/config.rs delete mode 100644 vessels/el-config/src/env.rs delete mode 100644 vessels/el-config/src/error.rs delete mode 100644 vessels/el-config/src/lib.rs delete mode 100644 vessels/el-config/src/source.rs delete mode 100644 vessels/el-i18n/Cargo.toml delete mode 100644 vessels/el-i18n/src/bundle.rs delete mode 100644 vessels/el-i18n/src/format.rs delete mode 100644 vessels/el-i18n/src/lib.rs delete mode 100644 vessels/el-i18n/src/locale.rs delete mode 100644 vessels/el-i18n/src/plural.rs delete mode 100644 vessels/el-i18n/src/t.rs delete mode 100644 vessels/el-identity/Cargo.toml delete mode 100644 vessels/el-identity/src/context.rs delete mode 100644 vessels/el-identity/src/engram.rs delete mode 100644 vessels/el-identity/src/error.rs delete mode 100644 vessels/el-identity/src/guard.rs delete mode 100644 vessels/el-identity/src/lib.rs delete mode 100644 vessels/el-identity/src/nodes.rs delete mode 100644 vessels/el-identity/src/oauth.rs delete mode 100644 vessels/el-identity/src/provider.rs delete mode 100644 vessels/el-identity/src/session.rs delete mode 100644 vessels/el-identity/src/tests.rs delete mode 100644 vessels/el-layout/Cargo.toml delete mode 100644 vessels/el-layout/src/breakpoint.rs delete mode 100644 vessels/el-layout/src/constraints.rs delete mode 100644 vessels/el-layout/src/flex.rs delete mode 100644 vessels/el-layout/src/grid.rs delete mode 100644 vessels/el-layout/src/lib.rs delete mode 100644 vessels/el-layout/src/platform.rs delete mode 100644 vessels/el-layout/src/responsive.rs delete mode 100644 vessels/el-layout/src/scroll.rs delete mode 100644 vessels/el-layout/src/stack.rs delete mode 100644 vessels/el-platform/Cargo.toml delete mode 100644 vessels/el-platform/src/backends/android.rs delete mode 100644 vessels/el-platform/src/backends/ios.rs delete mode 100644 vessels/el-platform/src/backends/linux.rs delete mode 100644 vessels/el-platform/src/backends/macos.rs delete mode 100644 vessels/el-platform/src/backends/mod.rs delete mode 100644 vessels/el-platform/src/backends/server.rs delete mode 100644 vessels/el-platform/src/backends/tests.rs delete mode 100644 vessels/el-platform/src/backends/web.rs delete mode 100644 vessels/el-platform/src/backends/windows.rs delete mode 100644 vessels/el-platform/src/config.rs delete mode 100644 vessels/el-platform/src/lib.rs delete mode 100644 vessels/el-platform/src/node.rs delete mode 100644 vessels/el-publish/Cargo.toml delete mode 100644 vessels/el-publish/src/apple.rs delete mode 100644 vessels/el-publish/src/cert.rs delete mode 100644 vessels/el-publish/src/config.rs delete mode 100644 vessels/el-publish/src/google.rs delete mode 100644 vessels/el-publish/src/lib.rs delete mode 100644 vessels/el-publish/src/metadata.rs delete mode 100644 vessels/el-publish/src/rollout.rs delete mode 100644 vessels/el-publish/src/screenshot.rs delete mode 100644 vessels/el-publish/src/tests.rs delete mode 100644 vessels/el-secrets/Cargo.toml delete mode 100644 vessels/el-secrets/src/error.rs delete mode 100644 vessels/el-secrets/src/lib.rs delete mode 100644 vessels/el-secrets/src/resolver.rs delete mode 100644 vessels/el-secrets/src/secret.rs delete mode 100644 vessels/el-secrets/src/source.rs delete mode 100644 vessels/el-services/Cargo.toml delete mode 100644 vessels/el-services/src/binding/direct.rs delete mode 100644 vessels/el-services/src/binding/grpc.rs delete mode 100644 vessels/el-services/src/binding/mod.rs delete mode 100644 vessels/el-services/src/binding/rest.rs delete mode 100644 vessels/el-services/src/binding/websocket.rs delete mode 100644 vessels/el-services/src/config.rs delete mode 100644 vessels/el-services/src/lib.rs delete mode 100644 vessels/el-services/src/proxy.rs delete mode 100644 vessels/el-services/src/registry.rs delete mode 100644 vessels/el-services/src/tests.rs delete mode 100644 vessels/el-style/Cargo.toml delete mode 100644 vessels/el-style/src/color.rs delete mode 100644 vessels/el-style/src/lib.rs delete mode 100644 vessels/el-style/src/modifier.rs delete mode 100644 vessels/el-style/src/radius.rs delete mode 100644 vessels/el-style/src/shadow.rs delete mode 100644 vessels/el-style/src/spacing.rs delete mode 100644 vessels/el-style/src/stylesheet.rs delete mode 100644 vessels/el-style/src/theme.rs delete mode 100644 vessels/el-style/src/typography.rs delete mode 100644 vessels/el-ui-compiler/Cargo.toml delete mode 100644 vessels/el-ui-compiler/src/ast.rs delete mode 100644 vessels/el-ui-compiler/src/codegen.rs delete mode 100644 vessels/el-ui-compiler/src/error.rs delete mode 100644 vessels/el-ui-compiler/src/lexer.rs delete mode 100644 vessels/el-ui-compiler/src/lib.rs delete mode 100644 vessels/el-ui-compiler/src/main.rs delete mode 100644 vessels/el-ui-compiler/src/parser.rs delete mode 100644 vessels/el-ui-compiler/src/semantic.rs delete mode 100644 vessels/el-ui-compiler/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index edb80c0..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,897 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[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 = "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 = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[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 = "cc" -version = "1.2.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" -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", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[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 = "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", - "subtle", -] - -[[package]] -name = "el-aop" -version = "0.1.0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "el-auth" -version = "0.1.0" -dependencies = [ - "base64", - "el-identity", - "hmac", - "sha2", - "thiserror", - "uuid", -] - -[[package]] -name = "el-config" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror", - "toml", -] - -[[package]] -name = "el-i18n" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror", - "toml", -] - -[[package]] -name = "el-identity" -version = "0.1.0" -dependencies = [ - "base64", - "chrono", - "hmac", - "serde", - "serde_json", - "sha2", - "thiserror", - "uuid", -] - -[[package]] -name = "el-layout" -version = "0.1.0" -dependencies = [ - "el-style", - "thiserror", -] - -[[package]] -name = "el-platform" -version = "0.1.0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "el-publish" -version = "0.1.0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "el-secrets" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "el-services" -version = "0.1.0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "el-style" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "el-ui-compiler" -version = "0.1.0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[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.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 = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[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 = "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 = "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 = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[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 = "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 = "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 = "profile-card" -version = "0.1.0" -dependencies = [ - "el-config", - "el-i18n", - "el-layout", - "el-secrets", - "el-style", -] - -[[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 = "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 = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[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 = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[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 = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "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 = "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 = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[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 = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[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 = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index e44b5c8..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[workspace] -members = [ - "vessels/el-ui-compiler", - "vessels/el-platform", - "vessels/el-services", - "vessels/el-aop", - "vessels/el-auth", - "vessels/el-publish", - "vessels/el-identity", - "vessels/el-style", - "vessels/el-layout", - "vessels/el-i18n", - "vessels/el-config", - "vessels/el-secrets", - "examples/profile-card", -] -resolver = "2" diff --git a/examples/profile-card/Cargo.toml b/examples/profile-card/Cargo.toml deleted file mode 100644 index 098248a..0000000 --- a/examples/profile-card/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "profile-card" -version = "0.1.0" -edition = "2021" -description = "el-ui profile card example — styling, layout, i18n, config, auth" - -[[bin]] -name = "profile-card" -path = "src/main.rs" - -[dependencies] -el-style = { path = "../../vessels/el-style" } -el-layout = { path = "../../vessels/el-layout" } -el-i18n = { path = "../../vessels/el-i18n" } -el-config = { path = "../../vessels/el-config" } -el-secrets = { path = "../../vessels/el-secrets" } diff --git a/examples/profile-card/src/main.rs b/examples/profile-card/src/main.rs deleted file mode 100644 index 7fd0d97..0000000 --- a/examples/profile-card/src/main.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! Profile card example — demonstrates el-ui styling, layout, i18n, config, and secrets. -//! -//! This example shows what building with el-ui looks like: -//! -//! - Styling via semantic tokens and the StyleModifier trait -//! - Responsive layout: VStack/HStack that wrap automatically -//! - Localization via LocaleContext and t()/t_plural() -//! - Configuration from manifest.el / env vars -//! - Secrets that never appear in logs - -use std::collections::HashMap; - -use el_config::prelude::*; -use el_i18n::prelude::*; -use el_layout::prelude::*; -use el_secrets::prelude::*; -use el_style::prelude::*; - -// --- Domain model --- - -struct UserProfile { - handle: String, - display_name: String, - bio: String, - follower_count: i64, - following_count: i64, - is_verified: bool, - is_following: bool, -} - -// --- Profile card component --- - -/// A profile card component. -/// -/// In a real el-ui app, this would be a .el component file compiled by -/// el-ui-compiler. Here we show the same patterns in pure Rust so the -/// example is self-contained and runnable. -struct ProfileCard { - profile: UserProfile, - theme: Theme, - style: StyleSet, - locale: LocaleContext, -} - -impl StyleModifier for ProfileCard { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -impl ProfileCard { - fn new(profile: UserProfile, theme: Theme, locale: LocaleContext) -> Self { - Self { - profile, - theme, - style: StyleSet::default(), - locale, - } - } - - /// "Render" the card — in a real app the backend converts this to - /// native views. Here we produce a human-readable description. - fn render(&self) -> String { - let t = &self.locale; - let theme = &self.theme; - - // --- Header HStack (avatar + name + verified badge) --- - // HStack wraps automatically if the container is narrow (mobile-first) - let header = HStack::new() - .spacing(12) - .alignment(VAlign::Center) - .wrap(true); - - // --- Name text: Title style --- - let name_style = theme.typography.resolve(&TextStyle::Title); - - // --- Body text: Body style --- - let body_style = theme.typography.resolve(&TextStyle::Body); - - // --- Stats HStack (followers / following) --- - let _stats_layout = HStack::new().spacing(24).wrap(true); - - // --- Follow button --- - // VStack wraps children that don't fit, so this works on any screen width - let _card_layout = VStack::new() - .spacing(16) - .alignment(HAlign::Leading) - .wrap(true); - - // --- Localized strings --- - let follow_label = if self.profile.is_following { - t.t("profile.following") - } else { - t.t("profile.follow") - }; - - let followers_label = - t.t_plural("profile.followers", self.profile.follower_count); - let following_label = - t.t_plural("profile.following_count", self.profile.following_count); - - // --- Color resolution --- - let (bg_r, bg_g, bg_b, _) = theme.colors.resolve(&Color::Surface); - let (text_r, text_g, text_b, _) = theme.colors.resolve(&Color::OnSurface); - let (primary_r, primary_g, primary_b, _) = theme.colors.resolve(&Color::Primary); - - // --- Shadow --- - let shadow_css = theme - .shadows - .resolve(&Shadow::Md) - .map(|s| s.to_css()) - .unwrap_or_default(); - - // --- Formatted stats (locale-aware numbers) --- - let follower_count_fmt = format_integer(self.profile.follower_count, &self.locale.locale); - let following_count_fmt = format_integer(self.profile.following_count, &self.locale.locale); - - // --- RTL layout signal --- - let layout_direction = if self.locale.is_rtl() { "rtl" } else { "ltr" }; - - // --- Build the output --- - format!( - r#" -┌─ ProfileCard ───────────────────────────────────────────── -│ Layout direction: {} -│ Theme mode: {:?} -│ -│ [Card background: rgb({},{},{}), shadow: {}] -│ -│ {} {} [Header HStack, spacing=12, wrap=true] -│ Avatar [44×44pt, radius=Full — meets touch target] -│ {} [font: {}pt weight={}, color: rgb({},{},{})] -│ {} [verified badge] -│ -│ {} [Body style, {}pt, color: rgb({},{},{})] -│ -│ [Stats HStack, spacing=24, wrap=true] -│ {} {} — follower count (locale-formatted) -│ {} {} — following count -│ -│ [Button: Primary variant] -│ {} [color: rgb({},{},{})] -│ -│ [Card ends] -└──────────────────────────────────────────────────────────── -"#, - layout_direction, - theme.mode, - bg_r, bg_g, bg_b, - shadow_css, - header.spacing, - if header.wrap { "wrap=true" } else { "wrap=false" }, - self.profile.display_name, - name_style.size, - name_style.weight.value(), - text_r, text_g, text_b, - if self.profile.is_verified { "✓" } else { "" }, - self.profile.bio, - body_style.size, - text_r, text_g, text_b, - follower_count_fmt, - followers_label, - following_count_fmt, - following_label, - follow_label, - primary_r, primary_g, primary_b, - ) - } -} - -// --- Application entry point --- - -fn main() { - // 1. Configuration — layered, typed - let el_toml = r#" -[config] -app.name = "ProfileCard Example" -app.version = "1.0.0" -profile.max_bio_length = "160" - -[env.development] -app.debug = "true" -"#; - - let toml_source = load_from_toml(el_toml, &Environment::Development) - .expect("manifest.el should be valid"); - - let mut config = Config::new(Environment::Development); - config.push_source(Box::new(toml_source)); - - let app_name = config.get::("app.name").unwrap_or_default(); - let app_version = config.get::("app.version").unwrap_or_default(); - let max_bio: u32 = config.get_or("profile.max_bio_length", 160u32); - let debug: bool = config.get_or("app.debug", false); - - println!("=== {} v{} ===", app_name, app_version); - println!("Environment: {}", config.environment); - println!("Debug mode: {}", debug); - println!("Max bio: {} chars", max_bio); - - // 2. Secrets — loaded at startup, never logged - let mut secret_src = InMemorySource::new(); - secret_src.insert("analytics.key", "ana_abc123xyz"); - - let secrets = SecretsResolver::new() - .source(Box::new(secret_src)) - .require("analytics.key") - .resolve() - .expect("required secrets must be present at startup"); - - let analytics_key = secrets.require("analytics.key"); - // This will always print [REDACTED] — never the actual key - println!("Analytics key: {} (safely [REDACTED] in logs)", analytics_key); - // To actually use it: - let _actual_key: &str = analytics_key.expose(); - - // 3. Localization — English - let mut en_bundle = LocaleBundle::new(Locale::en_us()); - en_bundle.insert("profile.follow", "Follow"); - en_bundle.insert("profile.following", "Following"); - let mut forms = HashMap::new(); - forms.insert("one".to_string(), "{n} Follower".to_string()); - forms.insert("other".to_string(), "{n} Followers".to_string()); - en_bundle.insert_plural("profile.followers", forms); - let mut following_forms = HashMap::new(); - following_forms.insert("one".to_string(), "{n} Following".to_string()); - following_forms.insert("other".to_string(), "{n} Following".to_string()); - en_bundle.insert_plural("profile.following_count", following_forms); - - let en_ctx = LocaleContext::new(Locale::en_us(), en_bundle); - - // 4. Theme — light, system colors - let light_theme = Theme::default_light(); - let dark_theme = Theme::default_dark(); - - // 5. Profile data - let profile = UserProfile { - handle: "alice".to_string(), - display_name: "Alice Chen".to_string(), - bio: "Building beautiful things with el-ui. Rust enthusiast.".to_string(), - follower_count: 12_483, - following_count: 342, - is_verified: true, - is_following: false, - }; - - // 6. Render the card (light theme, English) - println!("\n=== Light Theme, English ==="); - let card = ProfileCard::new( - UserProfile { - handle: profile.handle.clone(), - display_name: profile.display_name.clone(), - bio: profile.bio.clone(), - follower_count: profile.follower_count, - following_count: profile.following_count, - is_verified: profile.is_verified, - is_following: profile.is_following, - }, - light_theme, - en_ctx.clone(), - ); - // Apply style modifiers — fluent, zero-cost at compile time - let styled_card = card - .padding(16) - .background(Color::Surface) - .radius(Radius::Lg) - .shadow(Shadow::Md) - .max_width(Dimension::Fixed(480)); - - println!("{}", styled_card.render()); - - // 7. Render with dark theme - println!("=== Dark Theme, English ==="); - let card_dark = ProfileCard::new( - UserProfile { - handle: profile.handle.clone(), - display_name: profile.display_name.clone(), - bio: profile.bio.clone(), - follower_count: 1, // test singular - following_count: profile.following_count, - is_verified: profile.is_verified, - is_following: true, - }, - dark_theme, - en_ctx, - ); - let styled_dark = card_dark - .padding(16) - .background(Color::Surface) - .radius(Radius::Lg) - .shadow(Shadow::Lg); - - println!("{}", styled_dark.render()); - - // 8. Layout demonstration - println!("=== Layout Engine Demo ==="); - - // Grid: auto columns — picks 1, 2, 3... based on container width - let grid = GridLayout::new().columns_auto(200.0).gap(16); - for width in [300.0f32, 600.0, 900.0, 1200.0] { - println!( - " Container {}px → {} columns", - width, - grid.active_columns(width) - ); - } - - // Responsive value - let cols: Responsive = Responsive::fixed(1).md(2).lg(3); - println!("\n Responsive columns:"); - for bp in [ - Breakpoint::Base, - Breakpoint::Sm, - Breakpoint::Md, - Breakpoint::Lg, - Breakpoint::Xl, - ] { - println!(" {:?}: {} col(s)", bp, cols.resolve(bp)); - } - - // Platform sizing - let ios_sizing = PlatformSizing::for_platform(PlatformFamily::Ios); - let android_sizing = PlatformSizing::for_platform(PlatformFamily::Android); - println!("\n Min touch targets:"); - println!(" iOS: {}pt", ios_sizing.min_touch_target); - println!(" Android: {}dp", android_sizing.min_touch_target); - - // 9. Locale formatting - println!("\n=== Locale-Aware Formatting ==="); - let number = 1_234_567.89; - for (tag, locale) in [ - ("en-US", Locale::en_us()), - ("de-DE", Locale::new("de-DE")), - ("fr-FR", Locale::fr_fr()), - ("ja-JP", Locale::ja()), - ] { - let formatted = format_number(number, &locale, 2); - let currency = format_currency(1234.56, &locale, "USD"); - println!(" {}: {} | {}", tag, formatted, currency); - } - - // 10. RTL detection - println!("\n=== RTL Detection ==="); - for tag in ["en-US", "ar-SA", "he", "fa", "zh-TW"] { - let locale = Locale::new(tag); - println!(" {}: {}", tag, if locale.is_rtl() { "RTL" } else { "LTR" }); - } - - println!("\nAll systems operational. el-ui is ready."); -} diff --git a/vessels/el-aop/Cargo.toml b/vessels/el-aop/Cargo.toml deleted file mode 100644 index f5d43b0..0000000 --- a/vessels/el-aop/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "el-aop" -version = "0.1.0" -edition = "2021" -description = "el-ui aspect-oriented programming — cross-cutting concerns as first-class features" -license = "MIT" - -[lib] -name = "el_aop" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" - -[dev-dependencies] diff --git a/vessels/el-aop/src/aspects.rs b/vessels/el-aop/src/aspects.rs deleted file mode 100644 index cba9eb6..0000000 --- a/vessels/el-aop/src/aspects.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Built-in aspects for el-ui. - -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicU64, Ordering}, - Mutex, - }, - time::{Duration, Instant}, -}; - -use crate::{AopError, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn}; - -// ── @authenticate ───────────────────────────────────────────────────────────── - -/// `@authenticate` — Requires a valid session before the method executes. -/// -/// Checks `ctx.metadata["session_token"]` or `ctx.metadata["user_id"]`. -/// If absent, rejects with `AopError::Unauthenticated`. -pub struct AuthenticateAspect; - -impl Aspect for AuthenticateAspect { - fn name(&self) -> &'static str { - "authenticate" - } - - fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { - // Look for a session token or user ID in metadata. - // In production, the auth middleware populates these from the JWT/session. - let has_user = ctx.metadata.contains_key("user_id") - || ctx.metadata.contains_key("session_token"); - if !has_user { - return Err(AopError::Unauthenticated); - } - Ok(()) - } -} - -// ── @authorize ──────────────────────────────────────────────────────────────── - -/// `@authorize(role: "admin")` — Requires the caller to have a specific role. -pub struct AuthorizeAspect { - pub required_role: String, -} - -impl AuthorizeAspect { - pub fn new(role: impl Into) -> Self { - Self { required_role: role.into() } - } -} - -impl Aspect for AuthorizeAspect { - fn name(&self) -> &'static str { - "authorize" - } - - fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { - let user_roles = ctx - .metadata - .get("roles") - .map(|s| s.as_str()) - .unwrap_or(""); - // Roles are stored as comma-separated string: "admin,user" - let has_role = user_roles - .split(',') - .any(|r| r.trim() == self.required_role.as_str()); - if !has_role { - return Err(AopError::Forbidden { - role: self.required_role.clone(), - }); - } - Ok(()) - } -} - -// ── @cache ──────────────────────────────────────────────────────────────────── - -struct CacheEntry { - value: InvocationResult, - inserted_at: Instant, - ttl: Duration, -} - -impl CacheEntry { - fn is_expired(&self) -> bool { - self.inserted_at.elapsed() > self.ttl - } -} - -/// `@cache(ttl: 300)` — Cache method responses for `ttl` seconds. -/// -/// Cache key is `"target::method::{args_sorted_json}"`. -pub struct CacheAspect { - pub ttl: Duration, - cache: Mutex>, -} - -impl CacheAspect { - pub fn new(ttl_seconds: u64) -> Self { - Self { - ttl: Duration::from_secs(ttl_seconds), - cache: Mutex::new(HashMap::new()), - } - } - - fn cache_key(ctx: &InvocationContext) -> String { - let mut pairs: Vec<(&String, &String)> = ctx.args.iter().collect(); - pairs.sort_by_key(|(k, _)| k.as_str()); - let args = pairs - .iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(","); - format!("{}::{}::{}", ctx.target, ctx.method, args) - } -} - -impl Aspect for CacheAspect { - fn name(&self) -> &'static str { - "cache" - } - - fn around( - &self, - ctx: InvocationContext, - proceed: &ProceedFn, - ) -> AopResult { - let key = Self::cache_key(&ctx); - - // Check cache - { - let cache = self.cache.lock().expect("cache lock poisoned"); - if let Some(entry) = cache.get(&key) { - if !entry.is_expired() { - return Ok(entry.value.clone()); - } - } - } - - // Cache miss — proceed and store result - let result = proceed(ctx)?; - { - let mut cache = self.cache.lock().expect("cache lock poisoned"); - // Evict expired entries while we're here - cache.retain(|_, v| !v.is_expired()); - cache.insert( - key, - CacheEntry { - value: result.clone(), - inserted_at: Instant::now(), - ttl: self.ttl, - }, - ); - } - Ok(result) - } -} - -// ── @rate_limit ─────────────────────────────────────────────────────────────── - -struct RateWindow { - count: u32, - window_start: Instant, - window_duration: Duration, -} - -/// `@rate_limit(requests: 100, per: 60)` — Allow at most `requests` calls per `per` seconds. -pub struct RateLimitAspect { - pub max_requests: u32, - pub window: Duration, - state: Mutex>, -} - -impl RateLimitAspect { - pub fn new(max_requests: u32, per_seconds: u64) -> Self { - Self { - max_requests, - window: Duration::from_secs(per_seconds), - state: Mutex::new(HashMap::new()), - } - } - - /// The rate-limit key for a caller. Uses `user_id` or "anonymous". - fn caller_key(ctx: &InvocationContext) -> String { - ctx.metadata - .get("user_id") - .cloned() - .unwrap_or_else(|| "anonymous".to_string()) - } -} - -impl Aspect for RateLimitAspect { - fn name(&self) -> &'static str { - "rate_limit" - } - - fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { - let key = Self::caller_key(ctx); - let mut state = self.state.lock().expect("rate limit lock poisoned"); - let now = Instant::now(); - let window = state.entry(key).or_insert(RateWindow { - count: 0, - window_start: now, - window_duration: self.window, - }); - // Reset window if expired - if now.duration_since(window.window_start) >= window.window_duration { - window.count = 0; - window.window_start = now; - } - if window.count >= self.max_requests { - return Err(AopError::RateLimited { - requests: self.max_requests, - per: self.window.as_secs(), - }); - } - window.count += 1; - Ok(()) - } -} - -// ── @log ────────────────────────────────────────────────────────────────────── - -/// `@log(level: "info")` — Structured logging for every method call. -pub struct LogAspect { - pub level: String, -} - -impl LogAspect { - pub fn new(level: impl Into) -> Self { - Self { level: level.into() } - } -} - -impl Aspect for LogAspect { - fn name(&self) -> &'static str { - "log" - } - - fn around( - &self, - ctx: InvocationContext, - proceed: &ProceedFn, - ) -> AopResult { - // In production: use the `tracing` crate with the appropriate level macro. - let _log_entry = format!( - "[{}] {}.{}({:?})", - self.level.to_uppercase(), - ctx.target, - ctx.method, - ctx.args - ); - let result = proceed(ctx.clone()); - let _log_result = match &result { - Ok(r) => format!("[{}] {}.{} → ok: {}", self.level.to_uppercase(), ctx.target, ctx.method, r.value), - Err(e) => format!("[ERROR] {}.{} → err: {}", ctx.target, ctx.method, e), - }; - result - } -} - -// ── @validate ───────────────────────────────────────────────────────────────── - -/// `@validate` — Run input validation before the method executes. -/// -/// Validation rules are registered per method. If no rules are registered, -/// the aspect passes through (fail-open for ease of adoption). -pub struct ValidateAspect { - /// `"target::method"` → list of validation rules (field, rule_name) - rules: Mutex>>, -} - -impl ValidateAspect { - pub fn new() -> Self { - Self { - rules: Mutex::new(HashMap::new()), - } - } - - /// Add a validation rule. `rule` is one of: "required", "email", "min:N", "max:N". - pub fn add_rule( - &self, - target: &str, - method: &str, - field: impl Into, - rule: impl Into, - ) { - let key = format!("{}::{}", target, method); - self.rules - .lock() - .expect("validate lock poisoned") - .entry(key) - .or_default() - .push((field.into(), rule.into())); - } - - fn validate_field(value: &str, rule: &str) -> AopResult<()> { - if rule == "required" && value.trim().is_empty() { - return Err(AopError::ValidationFailed("field is required".into())); - } - if rule == "email" && !value.contains('@') { - return Err(AopError::ValidationFailed(format!( - "'{}' is not a valid email", - value - ))); - } - if let Some(min_str) = rule.strip_prefix("min:") { - let min: usize = min_str.parse().unwrap_or(0); - if value.len() < min { - return Err(AopError::ValidationFailed(format!( - "minimum length is {}", - min - ))); - } - } - if let Some(max_str) = rule.strip_prefix("max:") { - let max: usize = max_str.parse().unwrap_or(usize::MAX); - if value.len() > max { - return Err(AopError::ValidationFailed(format!( - "maximum length is {}", - max - ))); - } - } - Ok(()) - } -} - -impl Default for ValidateAspect { - fn default() -> Self { - Self::new() - } -} - -impl Aspect for ValidateAspect { - fn name(&self) -> &'static str { - "validate" - } - - fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { - let key = format!("{}::{}", ctx.target, ctx.method); - let rules = self.rules.lock().expect("validate lock poisoned"); - if let Some(field_rules) = rules.get(&key) { - for (field, rule) in field_rules { - let value = ctx.args.get(field).map(|s| s.as_str()).unwrap_or(""); - Self::validate_field(value, rule)?; - } - } - Ok(()) - } -} - -// ── @retry ──────────────────────────────────────────────────────────────────── - -/// `@retry(attempts: 3, backoff: "exponential")` — Retry on failure. -pub struct RetryAspect { - pub attempts: u32, - pub backoff: BackoffStrategy, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum BackoffStrategy { - None, - Fixed(Duration), - Exponential { base: Duration }, -} - -impl RetryAspect { - pub fn new(attempts: u32) -> Self { - Self { attempts, backoff: BackoffStrategy::None } - } - - pub fn with_exponential_backoff(mut self, base_ms: u64) -> Self { - self.backoff = BackoffStrategy::Exponential { - base: Duration::from_millis(base_ms), - }; - self - } - - pub fn with_fixed_backoff(mut self, ms: u64) -> Self { - self.backoff = BackoffStrategy::Fixed(Duration::from_millis(ms)); - self - } - - fn sleep_duration(&self, attempt: u32) -> Duration { - match &self.backoff { - BackoffStrategy::None => Duration::ZERO, - BackoffStrategy::Fixed(d) => *d, - BackoffStrategy::Exponential { base } => { - // base * 2^attempt, capped at 30s - let factor = 1u64 << attempt.min(10); - std::cmp::min(*base * factor as u32, Duration::from_secs(30)) - } - } - } -} - -impl Aspect for RetryAspect { - fn name(&self) -> &'static str { - "retry" - } - - fn around( - &self, - ctx: InvocationContext, - proceed: &ProceedFn, - ) -> AopResult { - let mut last_error = String::new(); - for attempt in 0..self.attempts { - match proceed(ctx.clone()) { - Ok(result) => return Ok(result), - Err(e) => { - last_error = e.to_string(); - let sleep_for = self.sleep_duration(attempt); - if sleep_for > Duration::ZERO && attempt + 1 < self.attempts { - std::thread::sleep(sleep_for); - } - } - } - } - Err(AopError::RetriesExhausted { - attempts: self.attempts, - last_error, - }) - } -} - -// ── @trace ──────────────────────────────────────────────────────────────────── - -static TRACE_COUNTER: AtomicU64 = AtomicU64::new(1); - -/// `@trace` — Add a distributed tracing span to every method call. -/// -/// Injects a `trace_id` and `span_id` into context metadata. -/// In production, emit the span to an OpenTelemetry collector. -pub struct TraceAspect { - pub service_name: String, -} - -impl TraceAspect { - pub fn new(service_name: impl Into) -> Self { - Self { service_name: service_name.into() } - } - - fn new_span_id() -> String { - let id = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("span-{:016x}", id) - } -} - -impl Aspect for TraceAspect { - fn name(&self) -> &'static str { - "trace" - } - - fn around( - &self, - mut ctx: InvocationContext, - proceed: &ProceedFn, - ) -> AopResult { - // Create or inherit trace ID - let trace_id = ctx - .metadata - .get("trace_id") - .cloned() - .unwrap_or_else(|| format!("trace-{:016x}", TRACE_COUNTER.load(Ordering::Relaxed))); - let span_id = Self::new_span_id(); - - ctx.metadata.insert("trace_id".into(), trace_id.clone()); - ctx.metadata.insert("span_id".into(), span_id.clone()); - - let start = Instant::now(); - let result = proceed(ctx.clone()); - let duration_us = start.elapsed().as_micros(); - - // In production: emit span to OpenTelemetry: - // tracer.start_with_context("method_call", parent_cx) - // .set_attribute(KeyValue::new("service", self.service_name.clone())) - // .set_attribute(KeyValue::new("method", ctx.method.clone())) - // .set_attribute(KeyValue::new("duration_us", duration_us as i64)) - // .end(); - let _ = duration_us; - - result.map(|mut r| { - r.metadata.insert("trace_id".into(), trace_id); - r.metadata.insert("span_id".into(), span_id); - r - }) - } -} diff --git a/vessels/el-aop/src/chain.rs b/vessels/el-aop/src/chain.rs deleted file mode 100644 index 44ea739..0000000 --- a/vessels/el-aop/src/chain.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Aspect chain — ordered execution of aspects around a method call. -//! -//! Aspects execute in order: each one wraps the next, forming a chain. -//! The innermost item is the actual method invocation. -//! -//! ```text -//! @authenticate → @authorize → @cache → @log → [method body] -//! before before check log -//! hit? ──→ return cached -//! miss? → [method body] → store → after-log -//! ``` -//! -//! ## Security-by-default -//! -//! `AspectChain::with_default_auth()` prepends `AuthenticateAspect` to every -//! chain. Call this when building chains for non-`@public` functions. - -use crate::{aspects::AuthenticateAspect, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn}; -use std::sync::Arc; - -/// An ordered chain of aspects applied to a single method. -pub struct AspectChain { - aspects: Vec>, -} - -impl AspectChain { - pub fn new() -> Self { - Self { aspects: Vec::new() } - } - - /// Add an aspect to the end of the chain. - pub fn add(mut self, aspect: Arc) -> Self { - self.aspects.push(aspect); - self - } - - /// Number of aspects in this chain. - pub fn len(&self) -> usize { - self.aspects.len() - } - - pub fn is_empty(&self) -> bool { - self.aspects.is_empty() - } - - /// Execute the chain around the given proceed function. - /// - /// Aspects run in order (left to right in the decorator list). - /// Each aspect's `around` method receives the next aspect's `around` - /// as the `proceed` function, forming a true onion model. - pub fn execute( - &self, - ctx: InvocationContext, - proceed: ProceedFn, - ) -> AopResult { - if self.aspects.is_empty() { - return proceed(ctx); - } - self.run_aspect(0, ctx, proceed) - } - - fn run_aspect( - &self, - index: usize, - ctx: InvocationContext, - final_proceed: ProceedFn, - ) -> AopResult { - if index >= self.aspects.len() { - return final_proceed(ctx); - } - - let aspect = self.aspects[index].clone(); - let remaining_aspects = self.aspects[index + 1..].to_vec(); - let final_proceed = Arc::new(final_proceed); - - let next: ProceedFn = Box::new(move |ctx: InvocationContext| { - if remaining_aspects.is_empty() { - return final_proceed(ctx); - } - - // Build remaining chain recursively - let sub_chain = AspectChain { - aspects: remaining_aspects.clone(), - }; - sub_chain.execute(ctx, { - let fp = final_proceed.clone(); - Box::new(move |ctx| fp(ctx)) - }) - }); - - aspect.around(ctx, &next) - } - - /// Return the names of all aspects in this chain (in order). - pub fn aspect_names(&self) -> Vec<&str> { - self.aspects.iter().map(|a| a.name()).collect() - } - - /// Prepend `AuthenticateAspect` to this chain. - /// - /// This is the mechanism for security-by-default: the framework calls - /// `with_default_auth()` on every chain that does NOT have `@public`. - /// - /// Equivalent to `.add(Arc::new(AuthenticateAspect))` at position 0, but - /// semantically explicit about what it means. - pub fn with_default_auth(self) -> Self { - let mut aspects = vec![Arc::new(AuthenticateAspect) as Arc]; - aspects.extend(self.aspects); - Self { aspects } - } - - /// Returns `true` if the chain contains an `AuthenticateAspect`. - pub fn has_auth(&self) -> bool { - self.aspects.iter().any(|a| a.name() == "authenticate") - } -} - -impl Default for AspectChain { - fn default() -> Self { - Self::new() - } -} diff --git a/vessels/el-aop/src/lib.rs b/vessels/el-aop/src/lib.rs deleted file mode 100644 index 4221234..0000000 --- a/vessels/el-aop/src/lib.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! el-aop — Aspect-Oriented Programming for el-ui. -//! -//! Cross-cutting concerns as first-class language features. Not a library you -//! import. Built into the framework. Applied as decorators: -//! -//! ```text -//! @authenticate ← applied by DEFAULT to every function -//! @authorize(role: "admin") -//! @cache(ttl: 300) -//! @rate_limit(requests: 100, per: 60) -//! component AdminDashboard { ... } -//! -//! @public ← explicit opt-out of authentication -//! component LandingPage { ... } -//! ``` -//! -//! ## Security-by-default -//! -//! `@authenticate` is the default. Functions without `@public` are protected. -//! This makes it as hard as possible to accidentally ship an unprotected endpoint. - -pub mod aspects; -pub mod chain; -pub mod registry; - -pub use aspects::{ - AuthenticateAspect, AuthorizeAspect, CacheAspect, LogAspect, RateLimitAspect, RetryAspect, - TraceAspect, ValidateAspect, -}; -pub use chain::AspectChain; -pub use public::PublicMarker; -pub use registry::AspectRegistry; - -pub mod public; - -#[cfg(test)] -mod tests; - -use std::collections::HashMap; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AopError { - #[error("authentication required")] - Unauthenticated, - #[error("forbidden: requires role '{role}'")] - Forbidden { role: String }, - #[error("rate limit exceeded: {requests} requests per {per}s")] - RateLimited { requests: u32, per: u64 }, - #[error("validation failed: {0}")] - ValidationFailed(String), - #[error("aspect error: {0}")] - Aspect(String), - #[error("all {attempts} retry attempts failed: {last_error}")] - RetriesExhausted { attempts: u32, last_error: String }, -} - -pub type AopResult = Result; - -/// Context passed through the aspect chain. -/// -/// Contains the incoming arguments and metadata about the call. -/// Aspects can read and mutate this context as they execute. -#[derive(Debug, Clone)] -pub struct InvocationContext { - /// The component or service being called. - pub target: String, - /// The method being called. - pub method: String, - /// Arguments passed to the method. - pub args: HashMap, - /// Metadata added by aspects (e.g., the authenticated user, trace ID). - pub metadata: HashMap, -} - -impl InvocationContext { - pub fn new(target: impl Into, method: impl Into) -> Self { - Self { - target: target.into(), - method: method.into(), - args: HashMap::new(), - metadata: HashMap::new(), - } - } - - pub fn with_arg(mut self, key: impl Into, value: impl Into) -> Self { - self.args.insert(key.into(), value.into()); - self - } - - pub fn with_meta(mut self, key: impl Into, value: impl Into) -> Self { - self.metadata.insert(key.into(), value.into()); - self - } - - pub fn get_meta(&self, key: &str) -> Option<&str> { - self.metadata.get(key).map(|s| s.as_str()) - } -} - -/// The result of invoking a method through an aspect chain. -#[derive(Debug, Clone)] -pub struct InvocationResult { - pub value: String, - pub metadata: HashMap, -} - -impl InvocationResult { - pub fn new(value: impl Into) -> Self { - Self { - value: value.into(), - metadata: HashMap::new(), - } - } -} - -/// A handler that performs the actual method invocation. -/// Aspects wrap around this. -pub type ProceedFn = Box AopResult + Send + Sync>; - -/// The core Aspect trait. -/// -/// Each aspect implements `before`, `after`, or `around` advice. -/// The default implementations are no-ops — only override what you need. -pub trait Aspect: Send + Sync { - /// The aspect's name (used for debugging and registry lookup). - fn name(&self) -> &'static str; - - /// Before advice — runs before the method. Can reject the call. - fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { - let _ = ctx; - Ok(()) - } - - /// After advice — runs after the method. Receives the result. - /// Can modify the result or perform cleanup. - fn after( - &self, - ctx: &InvocationContext, - result: AopResult, - ) -> AopResult { - let _ = ctx; - result - } - - /// Around advice — wraps the entire invocation. - /// The default implementation calls `before`, then `proceed`, then `after`. - fn around( - &self, - mut ctx: InvocationContext, - proceed: &ProceedFn, - ) -> AopResult { - self.before(&mut ctx)?; - let result = proceed(ctx.clone()); - self.after(&ctx, result) - } -} diff --git a/vessels/el-aop/src/public.rs b/vessels/el-aop/src/public.rs deleted file mode 100644 index 37eccaa..0000000 --- a/vessels/el-aop/src/public.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! `@public` marker — the explicit opt-out of authentication. -//! -//! The security-by-default model: `@authenticate` is applied to EVERY function -//! by default. `@public` is the rare annotation that says "this endpoint -//! intentionally has no auth". -//! -//! `PublicMarker` is a zero-cost marker. When the compiler sees `@public` on a -//! function, it strips `AuthenticateAspect` from the chain for that function. -//! The chain-builder checks `is_public` before prepending default auth. - -/// Zero-cost marker indicating that a function is intentionally public. -/// -/// When `@public` is present, the default `AuthenticateAspect` is NOT added -/// to the function's aspect chain. -/// -/// Usage in the el-ui compiler: -/// ```text -/// @public -/// fn health_check() -> Status { ... } -/// ``` -/// -/// In the AOP chain builder: -/// ```rust -/// use el_aop::{AspectChain, PublicMarker, AuthenticateAspect}; -/// use std::sync::Arc; -/// -/// fn build_chain(is_public: bool) -> AspectChain { -/// if is_public || PublicMarker::is_bypassing() { -/// AspectChain::new() -/// } else { -/// AspectChain::new().with_default_auth() -/// } -/// } -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct PublicMarker; - -impl PublicMarker { - /// Returns `true` — always. Exists for use in match arms / conditional logic. - /// - /// The presence of a `PublicMarker` in the decorator list is the signal; - /// this method is a convenience for procedural logic over decorator lists. - pub const fn is_bypassing() -> bool { - true - } - - /// The decorator name this marker corresponds to. - pub const fn decorator_name() -> &'static str { - "public" - } -} - -impl std::fmt::Display for PublicMarker { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "@public") - } -} diff --git a/vessels/el-aop/src/registry.rs b/vessels/el-aop/src/registry.rs deleted file mode 100644 index 123df5f..0000000 --- a/vessels/el-aop/src/registry.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! Aspect registry — register built-in and custom aspects by name. -//! -//! The compiler's AOP codegen uses the registry to look up aspect implementations -//! by their decorator name (e.g., `"authenticate"` → `AuthenticateAspect`). -//! -//! ## Security-by-default -//! -//! `"public"` is a special bypass marker — NOT an aspect. When the compiler sees -//! `@public` it calls `registry.is_public_bypass(name)` and skips default auth. -//! -//! `set_default_auth_guard()` installs the global default `AuthenticateAspect` -//! that is prepended to every non-`@public` chain. - -use crate::Aspect; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -type AspectFactory = Box) -> Arc + Send + Sync>; - -/// Registry of aspect factories, indexed by decorator name. -pub struct AspectRegistry { - factories: RwLock>, - /// When `true`, the registry is configured to prepend AuthenticateAspect - /// to every non-`@public` chain via `AspectChain::with_default_auth()`. - default_auth_enabled: RwLock, - /// Names that are bypass markers (not real aspects). Currently just "public". - bypass_markers: RwLock>, -} - -impl AspectRegistry { - pub fn new() -> Self { - let mut markers = std::collections::HashSet::new(); - markers.insert("public".to_string()); - Self { - factories: RwLock::new(HashMap::new()), - default_auth_enabled: RwLock::new(false), - bypass_markers: RwLock::new(markers), - } - } - - /// Create a registry with all built-in aspects registered. - pub fn with_builtins() -> Self { - let registry = Self::new(); - registry.register_builtins(); - registry - } - - /// Enable security-by-default: every non-`@public` chain will have - /// `AuthenticateAspect` prepended automatically. - /// - /// Call at application startup. After this, use `AspectChain::with_default_auth()` - /// when building chains for protected functions. - pub fn set_default_auth_enabled(&self, enabled: bool) { - *self.default_auth_enabled.write().expect("registry lock poisoned") = enabled; - } - - /// Returns `true` if security-by-default auth is active. - pub fn is_default_auth_enabled(&self) -> bool { - *self.default_auth_enabled.read().expect("registry lock poisoned") - } - - /// Returns `true` if `name` is a public bypass marker (e.g., `"public"`). - /// - /// Bypass markers are NOT aspects — they signal that default auth should - /// be skipped for the decorated function. - pub fn is_public_bypass(&self, name: &str) -> bool { - self.bypass_markers - .read() - .expect("registry lock poisoned") - .contains(name) - } - - /// Register a custom bypass marker name. - /// - /// By default only `"public"` is registered. Use this to add custom - /// bypass annotations (e.g., `"internal_only"` that uses a different guard). - pub fn register_bypass_marker(&self, name: &str) { - self.bypass_markers - .write() - .expect("registry lock poisoned") - .insert(name.to_string()); - } - - /// Build an `AspectChain` for a function with the given decorators. - /// - /// This is the primary chain-building entry point used by the AOP codegen. - /// - /// - If any decorator is a bypass marker (`@public`), returns a plain chain - /// with no default auth. - /// - Otherwise, if `default_auth_enabled`, prepends `AuthenticateAspect`. - /// - Unknown decorator names are silently skipped (forward-compatible). - pub fn build_chain(&self, decorator_names: &[(&str, HashMap)]) -> crate::AspectChain { - let is_public = decorator_names.iter().any(|(name, _)| self.is_public_bypass(name)); - - let mut chain = crate::AspectChain::new(); - - for (name, params) in decorator_names { - if self.is_public_bypass(name) { - continue; // bypass markers are not aspects - } - if let Some(aspect) = self.create(name, params) { - chain = chain.add(aspect); - } - } - - if !is_public && self.is_default_auth_enabled() { - chain = chain.with_default_auth(); - } - - chain - } - - /// Register all built-in aspects. - pub fn register_builtins(&self) { - use crate::aspects::*; - - self.register("authenticate", |_params| { - Arc::new(AuthenticateAspect) - }); - - self.register("authorize", |params| { - let role = params - .get("role") - .cloned() - .unwrap_or_else(|| "user".to_string()); - Arc::new(AuthorizeAspect::new(role)) - }); - - self.register("cache", |params| { - let ttl: u64 = params - .get("ttl") - .and_then(|s| s.parse().ok()) - .unwrap_or(300); - Arc::new(CacheAspect::new(ttl)) - }); - - self.register("rate_limit", |params| { - let requests: u32 = params - .get("requests") - .and_then(|s| s.parse().ok()) - .unwrap_or(100); - let per: u64 = params - .get("per") - .and_then(|s| s.parse().ok()) - .unwrap_or(60); - Arc::new(RateLimitAspect::new(requests, per)) - }); - - self.register("log", |params| { - let level = params - .get("level") - .cloned() - .unwrap_or_else(|| "info".to_string()); - Arc::new(LogAspect::new(level)) - }); - - self.register("validate", |_params| Arc::new(ValidateAspect::new())); - - self.register("retry", |params| { - let attempts: u32 = params - .get("attempts") - .and_then(|s| s.parse().ok()) - .unwrap_or(3); - let backoff = params - .get("backoff") - .map(|s| s.as_str()) - .unwrap_or("none"); - let aspect = RetryAspect::new(attempts); - let aspect = match backoff { - "exponential" => aspect.with_exponential_backoff(100), - "fixed" => aspect.with_fixed_backoff(500), - _ => aspect, - }; - Arc::new(aspect) - }); - - self.register("trace", |params| { - let service = params - .get("service") - .cloned() - .unwrap_or_else(|| "el-ui".to_string()); - Arc::new(TraceAspect::new(service)) - }); - } - - /// Register a custom aspect factory. - pub fn register( - &self, - name: &str, - factory: impl Fn(&HashMap) -> Arc + Send + Sync + 'static, - ) { - self.factories - .write() - .expect("registry lock poisoned") - .insert(name.to_string(), Box::new(factory)); - } - - /// Instantiate an aspect by decorator name with the given params. - pub fn create( - &self, - name: &str, - params: &HashMap, - ) -> Option> { - let factories = self.factories.read().expect("registry lock poisoned"); - factories.get(name).map(|f| f(params)) - } - - /// List all registered aspect names. - pub fn aspect_names(&self) -> Vec { - self.factories - .read() - .expect("registry lock poisoned") - .keys() - .cloned() - .collect() - } - - /// Check if an aspect name is registered. - pub fn contains(&self, name: &str) -> bool { - self.factories - .read() - .expect("registry lock poisoned") - .contains_key(name) - } -} - -impl Default for AspectRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod registry_default_auth_tests { - use super::*; - - #[test] - fn test_default_auth_disabled_by_default() { - let reg = AspectRegistry::new(); - assert!(!reg.is_default_auth_enabled()); - } - - #[test] - fn test_set_default_auth_enabled() { - let reg = AspectRegistry::new(); - reg.set_default_auth_enabled(true); - assert!(reg.is_default_auth_enabled()); - } - - #[test] - fn test_public_is_bypass_marker() { - let reg = AspectRegistry::new(); - assert!(reg.is_public_bypass("public")); - assert!(!reg.is_public_bypass("authenticate")); - } - - #[test] - fn test_build_chain_public_skips_default_auth() { - let reg = AspectRegistry::with_builtins(); - reg.set_default_auth_enabled(true); - let decorators = vec![("public", HashMap::new())]; - let chain = reg.build_chain(&decorators); - assert!(!chain.has_auth(), "public chain should not have default auth"); - } - - #[test] - fn test_build_chain_non_public_gets_default_auth() { - let reg = AspectRegistry::with_builtins(); - reg.set_default_auth_enabled(true); - let decorators = vec![("log", HashMap::new())]; - let chain = reg.build_chain(&decorators); - assert!(chain.has_auth(), "non-public chain should have default auth prepended"); - } - - #[test] - fn test_build_chain_auth_is_first_aspect() { - let reg = AspectRegistry::with_builtins(); - reg.set_default_auth_enabled(true); - let decorators = vec![("log", HashMap::new())]; - let chain = reg.build_chain(&decorators); - let names = chain.aspect_names(); - assert_eq!(names[0], "authenticate", "authenticate must be first in the chain"); - } -} diff --git a/vessels/el-aop/src/tests.rs b/vessels/el-aop/src/tests.rs deleted file mode 100644 index 470a6aa..0000000 --- a/vessels/el-aop/src/tests.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Tests for el-aop. - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::Arc; - - use crate::{ - aspects::*, - chain::AspectChain, - registry::AspectRegistry, - AopError, Aspect, InvocationContext, InvocationResult, - }; - - fn succeed_proceed(value: impl Into + Clone) -> crate::ProceedFn { - let v = value.into(); - Box::new(move |_ctx| Ok(InvocationResult::new(v.clone()))) - } - - fn fail_proceed(msg: impl Into + Clone) -> crate::ProceedFn { - let m = msg.into(); - Box::new(move |_ctx| Err(AopError::Aspect(m.clone()))) - } - - fn ctx(target: &str, method: &str) -> InvocationContext { - InvocationContext::new(target, method) - } - - fn authed_ctx(target: &str, method: &str) -> InvocationContext { - ctx(target, method).with_meta("user_id", "user-123") - } - - fn admin_ctx(target: &str, method: &str) -> InvocationContext { - ctx(target, method) - .with_meta("user_id", "admin-1") - .with_meta("roles", "admin,user") - } - - // ── Test 1: AuthenticateAspect rejects unauthenticated calls ───────────── - #[test] - fn test_authenticate_rejects_unauthenticated() { - let aspect = AuthenticateAspect; - let mut ctx = ctx("AdminDashboard", "load"); - let result = aspect.before(&mut ctx); - assert!(result.is_err()); - assert!(matches!(result, Err(AopError::Unauthenticated))); - } - - // ── Test 2: AuthenticateAspect allows authenticated calls ───────────────── - #[test] - fn test_authenticate_allows_authenticated() { - let aspect = AuthenticateAspect; - let mut ctx = authed_ctx("AdminDashboard", "load"); - let result = aspect.before(&mut ctx); - assert!(result.is_ok()); - } - - // ── Test 3: AuthorizeAspect rejects wrong role ──────────────────────────── - #[test] - fn test_authorize_rejects_wrong_role() { - let aspect = AuthorizeAspect::new("admin"); - let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user"); - let result = aspect.before(&mut ctx); - assert!(matches!(result, Err(AopError::Forbidden { .. }))); - } - - // ── Test 4: AuthorizeAspect allows correct role ─────────────────────────── - #[test] - fn test_authorize_allows_correct_role() { - let aspect = AuthorizeAspect::new("admin"); - let mut ctx = admin_ctx("Dashboard", "delete"); - let result = aspect.before(&mut ctx); - assert!(result.is_ok()); - } - - // ── Test 5: CacheAspect returns cached result on second call ────────────── - #[test] - fn test_cache_returns_cached_result() { - let aspect = CacheAspect::new(300); - let ctx = authed_ctx("OrderService", "get_orders"); - let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); - let cc = call_count.clone(); - let proceed: crate::ProceedFn = Box::new(move |_ctx| { - let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - Ok(InvocationResult::new(format!("result-{}", n))) - }); - // First call — executes proceed - let r1 = aspect.around(ctx.clone(), &proceed).unwrap(); - // Second call — should return cached (proceed not called again) - let proceed2: crate::ProceedFn = Box::new(|_ctx| { - panic!("proceed should not be called on cache hit"); - }); - let r2 = aspect.around(ctx.clone(), &proceed2).unwrap(); - assert_eq!(r1.value, r2.value, "cached value should be returned"); - } - - // ── Test 6: RateLimitAspect blocks after limit exceeded ─────────────────── - #[test] - fn test_rate_limit_blocks_after_limit() { - let aspect = RateLimitAspect::new(2, 60); - let mut ctx = authed_ctx("OrderService", "create_order"); - - // First two calls succeed - assert!(aspect.before(&mut ctx).is_ok()); - assert!(aspect.before(&mut ctx).is_ok()); - // Third call should be blocked - let result = aspect.before(&mut ctx); - assert!(matches!(result, Err(AopError::RateLimited { .. }))); - } - - // ── Test 7: LogAspect passes through to proceed ─────────────────────────── - #[test] - fn test_log_aspect_passthrough() { - let aspect = LogAspect::new("info"); - let ctx = authed_ctx("UserService", "get_user"); - let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap(); - assert_eq!(result.value, "user-data"); - } - - // ── Test 8: ValidateAspect rejects required field missing ──────────────── - #[test] - fn test_validate_required_field() { - let aspect = ValidateAspect::new(); - aspect.add_rule("UserService", "create_user", "name", "required"); - let mut ctx = authed_ctx("UserService", "create_user"); - // No "name" arg - let result = aspect.before(&mut ctx); - assert!(matches!(result, Err(AopError::ValidationFailed(_)))); - } - - // ── Test 9: ValidateAspect passes when field is present ────────────────── - #[test] - fn test_validate_required_field_present() { - let aspect = ValidateAspect::new(); - aspect.add_rule("UserService", "create_user", "email", "email"); - let mut ctx = authed_ctx("UserService", "create_user") - .with_arg("email", "alice@example.com"); - assert!(aspect.before(&mut ctx).is_ok()); - } - - // ── Test 10: ValidateAspect rejects invalid email ───────────────────────── - #[test] - fn test_validate_email_rule() { - let aspect = ValidateAspect::new(); - aspect.add_rule("UserService", "create_user", "email", "email"); - let mut ctx = authed_ctx("UserService", "create_user") - .with_arg("email", "not-an-email"); - let result = aspect.before(&mut ctx); - assert!(matches!(result, Err(AopError::ValidationFailed(_)))); - } - - // ── Test 11: RetryAspect retries on failure ─────────────────────────────── - #[test] - fn test_retry_succeeds_on_third_attempt() { - let aspect = RetryAspect::new(3); - let ctx = authed_ctx("OrderService", "place_order"); - let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); - let attempt_clone = attempt.clone(); - let proceed: crate::ProceedFn = Box::new(move |_ctx| { - let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n < 2 { - Err(AopError::Aspect("transient error".into())) - } else { - Ok(InvocationResult::new("success")) - } - }); - let result = aspect.around(ctx, &proceed).unwrap(); - assert_eq!(result.value, "success"); - } - - // ── Test 12: RetryAspect exhausts all attempts ─────────────────────────── - #[test] - fn test_retry_exhausted() { - let aspect = RetryAspect::new(3); - let ctx = authed_ctx("OrderService", "place_order"); - let result = aspect.around(ctx, &fail_proceed("always fails")); - assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. }))); - } - - // ── Test 13: TraceAspect injects trace/span IDs ─────────────────────────── - #[test] - fn test_trace_aspect_injects_ids() { - let aspect = TraceAspect::new("el-ui"); - let ctx = authed_ctx("UserService", "get_user"); - let result = aspect.around(ctx, &succeed_proceed("data")).unwrap(); - assert!( - result.metadata.contains_key("trace_id"), - "should inject trace_id" - ); - assert!( - result.metadata.contains_key("span_id"), - "should inject span_id" - ); - } - - // ── Test 14: AspectChain executes aspects in order ──────────────────────── - #[test] - fn test_aspect_chain_ordering() { - let order = Arc::new(std::sync::Mutex::new(Vec::new())); - - struct OrderTracker { - name: &'static str, - order: Arc>>, - } - impl Aspect for OrderTracker { - fn name(&self) -> &'static str { self.name } - fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult { - self.order.lock().unwrap().push(self.name); - proceed(ctx) - } - } - - let chain = AspectChain::new() - .add(Arc::new(OrderTracker { name: "first", order: order.clone() })) - .add(Arc::new(OrderTracker { name: "second", order: order.clone() })) - .add(Arc::new(OrderTracker { name: "third", order: order.clone() })); - - let ctx = authed_ctx("MyService", "my_method"); - chain.execute(ctx, succeed_proceed("ok")).unwrap(); - - let recorded = order.lock().unwrap(); - assert_eq!(*recorded, vec!["first", "second", "third"]); - } - - // ── Test 15: AspectChain with auth + authorize rejects unauthenticated ──── - #[test] - fn test_aspect_chain_auth_flow() { - let chain = AspectChain::new() - .add(Arc::new(AuthenticateAspect)) - .add(Arc::new(AuthorizeAspect::new("admin"))); - - // Unauthenticated — should fail at authenticate - let ctx = ctx("AdminDashboard", "load"); - let result = chain.execute(ctx, succeed_proceed("ok")); - assert!(matches!(result, Err(AopError::Unauthenticated))); - - // Authenticated but wrong role — should fail at authorize - let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user"); - let result = chain.execute(ctx, succeed_proceed("ok")); - assert!(matches!(result, Err(AopError::Forbidden { .. }))); - - // Admin — should succeed - let ctx = admin_ctx("AdminDashboard", "load"); - let result = chain.execute(ctx, succeed_proceed("ok")); - assert!(result.is_ok()); - } - - // ── Test 16: AspectRegistry registers all builtins ──────────────────────── - #[test] - fn test_registry_has_builtins() { - let registry = AspectRegistry::with_builtins(); - for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] { - assert!(registry.contains(name), "should have built-in: {}", name); - } - } - - // ── Test 17: AspectRegistry creates aspects from params ─────────────────── - #[test] - fn test_registry_creates_aspect() { - let registry = AspectRegistry::with_builtins(); - let mut params = HashMap::new(); - params.insert("role".into(), "admin".into()); - let aspect = registry.create("authorize", ¶ms); - assert!(aspect.is_some(), "should create authorize aspect"); - assert_eq!(aspect.unwrap().name(), "authorize"); - } - - // ── Test 18: AspectRegistry::create returns None for unknown aspect ─────── - #[test] - fn test_registry_unknown_aspect() { - let registry = AspectRegistry::with_builtins(); - let result = registry.create("unknown_aspect", &HashMap::new()); - assert!(result.is_none()); - } - - // ── Test 19: CacheAspect with zero TTL doesn't serve stale data ────────── - #[test] - fn test_cache_zero_ttl() { - let aspect = CacheAspect::new(0); // Immediate expiry - let ctx = authed_ctx("Service", "method"); - let n = Arc::new(std::sync::atomic::AtomicU32::new(0)); - let nc = n.clone(); - // Both calls should hit proceed since ttl=0 means instant expiry - let p1: crate::ProceedFn = Box::new(move |_ctx| { - let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Ok(InvocationResult::new(v.to_string())) - }); - // Zero TTL will expire immediately; we just verify it doesn't panic - let _ = aspect.around(ctx.clone(), &p1); - // Second call should also execute proceed - let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh"))); - let r = aspect.around(ctx, &p2).unwrap(); - assert_eq!(r.value, "fresh"); - } - - // ── Test 20: Empty AspectChain calls proceed directly ───────────────────── - #[test] - fn test_empty_chain_calls_proceed() { - let chain = AspectChain::new(); - assert!(chain.is_empty()); - let ctx = authed_ctx("Service", "method"); - let result = chain.execute(ctx, succeed_proceed("direct")).unwrap(); - assert_eq!(result.value, "direct"); - } -} diff --git a/vessels/el-auth/Cargo.toml b/vessels/el-auth/Cargo.toml deleted file mode 100644 index 4211e77..0000000 --- a/vessels/el-auth/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "el-auth" -version = "0.1.0" -edition = "2021" -description = "el-ui built-in authentication and authorization — native to the framework" -license = "MIT" - -[lib] -name = "el_auth" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" -base64 = "0.22" -hmac = "0.12" -sha2 = "0.10" -el-identity = { path = "../el-identity" } -uuid = { version = "1", features = ["v4"] } - -[dev-dependencies] diff --git a/vessels/el-auth/src/context.rs b/vessels/el-auth/src/context.rs deleted file mode 100644 index 5cec55b..0000000 --- a/vessels/el-auth/src/context.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Auth context — the current authenticated user and their roles/permissions. - -/// The authenticated user. -#[derive(Debug, Clone)] -pub struct AuthUser { - pub id: String, - pub email: String, - pub name: String, -} - -impl AuthUser { - pub fn new( - id: impl Into, - email: impl Into, - name: impl Into, - ) -> Self { - Self { - id: id.into(), - email: email.into(), - name: name.into(), - } - } -} - -/// The auth context — populated by `AuthMiddleware` and available to all -/// components and services downstream in the request. -/// -/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP -/// layer (see `el-aop`). -#[derive(Debug, Clone)] -pub struct AuthContext { - pub user: Option, - pub roles: Vec, - pub permissions: Vec, - /// The raw token/session ID that was verified. - pub token: String, -} - -impl AuthContext { - pub fn anonymous() -> Self { - Self { - user: None, - roles: Vec::new(), - permissions: Vec::new(), - token: String::new(), - } - } - - pub fn authenticated(user: AuthUser, roles: Vec, token: impl Into) -> Self { - Self { - user: Some(user), - roles, - permissions: Vec::new(), - token: token.into(), - } - } - - pub fn with_permissions(mut self, perms: Vec) -> Self { - self.permissions = perms; - self - } - - pub fn is_authenticated(&self) -> bool { - self.user.is_some() - } - - pub fn has_role(&self, role: &str) -> bool { - self.roles.iter().any(|r| r == role) - } - - pub fn has_permission(&self, permission: &str) -> bool { - self.permissions.iter().any(|p| p == permission) - } - - pub fn user_id(&self) -> Option<&str> { - self.user.as_ref().map(|u| u.id.as_str()) - } -} diff --git a/vessels/el-auth/src/engram_session.rs b/vessels/el-auth/src/engram_session.rs deleted file mode 100644 index 64d0346..0000000 --- a/vessels/el-auth/src/engram_session.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! EngramSessionStore — a `SessionProvider`-compatible store backed by the Engram graph. -//! -//! Sessions are Engram graph nodes (via el-identity's `SessionManager`). -//! This allows server-side invalidation even for stateless JWT workflows: -//! on every request, the JWT's `session_id` claim is used to look up the -//! Session node in Engram. If the node is missing or expired, the request -//! is rejected regardless of JWT validity. -//! -//! Dependency: takes `Arc` — soft dependency, no tight coupling -//! to the Engram crate. - -use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry}; -use el_identity::{ - engram::EngramClient, - session::SessionManager, -}; -use std::sync::Arc; - -/// Session provider backed by the Engram identity graph. -/// -/// Sessions issued by this provider are stored as Engram Session nodes. -/// The session ID returned is the Engram node's UUID, which is also embedded -/// in JWTs via `JwtClaims::session_id`. -pub struct EngramSessionStore { - session_manager: Arc, - client: Arc, -} - -impl EngramSessionStore { - /// Create a new `EngramSessionStore`. - /// - /// `client` is the Engram graph client. In production, pass your real - /// Engram client. In tests, use `el_identity::engram::MockEngramClient`. - pub fn new(client: Arc) -> Self { - let sm = Arc::new(SessionManager::new(client.clone())); - Self { session_manager: sm, client } - } - - /// Create with a custom session TTL (seconds). - pub fn with_ttl(client: Arc, ttl_seconds: i64) -> Self { - let sm = Arc::new(SessionManager::new(client.clone()).with_ttl(ttl_seconds)); - Self { session_manager: sm, client } - } - - /// Expose the underlying SessionManager for advanced use (e.g., listing sessions). - pub fn session_manager(&self) -> &Arc { - &self.session_manager - } -} - -impl AuthProvider for EngramSessionStore { - fn name(&self) -> &'static str { - "engram_session" - } - - /// Verify a session by its ID (Engram Session node UUID). - /// - /// Validates expiry via graph lookup. Returns `AuthContext` populated from - /// the Session and User nodes. - fn verify(&self, session_id: &str) -> AuthResult { - // Validate session node (checks expiry, lazy-deletes expired) - let session = self - .session_manager - .validate(session_id) - .map_err(|e| match e { - el_identity::IdentityError::SessionNotFound => AuthError::SessionNotFound, - el_identity::IdentityError::SessionExpired => AuthError::SessionNotFound, - other => AuthError::Config(other.to_string()), - })?; - - // Load user node - let user_id_str = session.user_id.to_string(); - let user_node = self - .client - .get_node(&user_id_str) - .map_err(|e| AuthError::Config(e.to_string()))? - .ok_or(AuthError::InvalidCredentials)?; - - let identity_user = el_identity::User::from_value(&user_node) - .ok_or_else(|| AuthError::Config("user node parse failed".into()))?; - - let auth_user = AuthUser::new( - identity_user.id.to_string(), - &identity_user.email, - &identity_user.display_name, - ); - - // Load roles via has_role edges - let role_nodes = self - .client - .find_connected(&user_id_str, el_identity::nodes::EDGE_HAS_ROLE) - .map_err(|e| AuthError::Config(e.to_string()))?; - - let role_names: Vec = role_nodes - .iter() - .filter_map(el_identity::Role::from_value) - .map(|r| r.name) - .collect(); - - Ok(AuthContext::authenticated(auth_user, role_names, session_id)) - } - - /// Issue a new Engram-backed session for the given user. - /// - /// The user must already exist as a User node in Engram. Returns the - /// session ID (UUID string) which should be embedded in the JWT's - /// `session_id` claim. - fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult { - // Parse user ID as UUID - let user_uuid = uuid::Uuid::parse_str(&user.id) - .map_err(|_| AuthError::Config(format!("invalid user ID UUID: {}", user.id)))?; - - let session = self - .session_manager - .create(user_uuid, None) - .map_err(|e| AuthError::Config(e.to_string()))?; - - Ok(session.id.to_string()) - } - - /// Revoke a session by deleting the Session node from the graph. - fn revoke(&self, session_id: &str) -> AuthResult<()> { - self.session_manager - .invalidate(session_id) - .map_err(|e| AuthError::Config(e.to_string())) - } -} - diff --git a/vessels/el-auth/src/jwt.rs b/vessels/el-auth/src/jwt.rs deleted file mode 100644 index e57c187..0000000 --- a/vessels/el-auth/src/jwt.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! JWT provider — sign and verify JSON Web Tokens. -//! -//! Uses HMAC-SHA256 (HS256) for signing. Does NOT use the `jsonwebtoken` crate -//! to keep dependencies minimal; implements the JWT spec directly. -//! -//! Format: base64url(header).base64url(payload).base64url(signature) - -use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry}; -use hmac::{Hmac, Mac}; -use sha2::Sha256; - -type HmacSha256 = Hmac; - -/// JWT claims payload. -/// -/// The `session_id` field is included so the Engram session node can be -/// validated on every request, enabling server-side session invalidation -/// even for stateless JWTs. -#[derive(Debug, Clone)] -pub struct JwtClaims { - pub sub: String, // user ID - pub email: String, - pub name: String, - pub roles: Vec, - /// The Engram Session node ID. Used by `EngramSessionStore` to validate - /// the session graph node on every request, enabling server-side logout. - pub session_id: Option, - pub iat: u64, // issued-at (unix seconds) - pub exp: u64, // expiry (unix seconds) -} - -impl JwtClaims { - pub fn new(user: &AuthUser, roles: Vec, ttl_seconds: u64) -> Self { - let now = unix_now(); - Self { - sub: user.id.clone(), - email: user.email.clone(), - name: user.name.clone(), - roles, - session_id: None, - iat: now, - exp: now + ttl_seconds, - } - } - - /// Create claims with an Engram session ID embedded. - pub fn new_with_session( - user: &AuthUser, - roles: Vec, - session_id: impl Into, - ttl_seconds: u64, - ) -> Self { - let mut claims = Self::new(user, roles, ttl_seconds); - claims.session_id = Some(session_id.into()); - claims - } - - pub fn is_expired(&self) -> bool { - unix_now() > self.exp - } - - /// Serialize claims to JSON (manual, no serde dependency). - pub fn to_json(&self) -> String { - let roles_json = self - .roles - .iter() - .map(|r| format!("\"{}\"", r)) - .collect::>() - .join(","); - // Build the JSON manually, inserting session_id only when present. - let mut json = format!( - "{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}]", - self.sub, self.email, self.name, roles_json - ); - if let Some(sid) = &self.session_id { - json.push_str(&format!(",\"session_id\":\"{}\"", sid)); - } - json.push_str(&format!(",\"iat\":{},\"exp\":{}}}", self.iat, self.exp)); - json - } - - /// Deserialize claims from JSON (manual parser). - pub fn from_json(json: &str) -> Option { - let sub = extract_str(json, "sub")?; - let email = extract_str(json, "email").unwrap_or_default(); - let name = extract_str(json, "name").unwrap_or_default(); - let iat = extract_u64(json, "iat").unwrap_or(0); - let exp = extract_u64(json, "exp").unwrap_or(0); - let roles = extract_str_array(json, "roles"); - let session_id = extract_str(json, "session_id"); - Some(Self { sub, email, name, roles, session_id, iat, exp }) - } -} - -/// JWT provider — issues and verifies HS256 JWTs. -pub struct JwtProvider { - secret: Vec, - /// Token TTL in seconds (default: 3600 = 1 hour). - pub ttl_seconds: u64, -} - -impl JwtProvider { - pub fn new(secret: impl Into>) -> Self { - Self { secret: secret.into(), ttl_seconds: 3600 } - } - - pub fn from_env(env_var: &str) -> AuthResult { - let secret = std::env::var(env_var).map_err(|_| { - AuthError::Config(format!("env var {} not set", env_var)) - })?; - Ok(Self::new(secret.into_bytes())) - } - - pub fn with_ttl(mut self, seconds: u64) -> Self { - self.ttl_seconds = seconds; - self - } - - /// Sign a token with HMAC-SHA256. - fn sign(&self, header_payload: &str) -> String { - let mut mac = HmacSha256::new_from_slice(&self.secret) - .expect("HMAC can take key of any size"); - mac.update(header_payload.as_bytes()); - let result = mac.finalize(); - base64url_encode(&result.into_bytes()) - } - - /// Encode a JWT token from claims. - pub fn encode(&self, claims: &JwtClaims) -> String { - let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); - let payload = base64url_encode(claims.to_json().as_bytes()); - let header_payload = format!("{}.{}", header, payload); - let signature = self.sign(&header_payload); - format!("{}.{}", header_payload, signature) - } - - /// Decode and verify a JWT token. - pub fn decode(&self, token: &str) -> AuthResult { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err(AuthError::TokenInvalid("not a valid JWT".into())); - } - - let header_payload = format!("{}.{}", parts[0], parts[1]); - let expected_sig = self.sign(&header_payload); - if !constant_time_eq(parts[2], &expected_sig) { - return Err(AuthError::TokenInvalid("signature mismatch".into())); - } - - let payload_bytes = base64url_decode(parts[1]) - .ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?; - let payload_str = String::from_utf8(payload_bytes) - .map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?; - - let claims = JwtClaims::from_json(&payload_str) - .ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?; - - if claims.is_expired() { - return Err(AuthError::TokenExpired); - } - - Ok(claims) - } -} - -impl AuthProvider for JwtProvider { - fn name(&self) -> &'static str { - "jwt" - } - - fn verify(&self, token: &str) -> AuthResult { - let claims = self.decode(token)?; - let user = AuthUser::new(&claims.sub, &claims.email, &claims.name); - Ok(AuthContext::authenticated(user, claims.roles, token)) - } - - fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult { - let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds); - Ok(self.encode(&claims)) - } - - fn revoke(&self, _token: &str) -> AuthResult<()> { - // JWTs are stateless — revocation requires a blocklist. - // TODO: maintain a revocation list (in-memory or Redis). - Ok(()) - } -} - -// ── Crypto helpers ───────────────────────────────────────────────────────────── - -fn base64url_encode(input: &[u8]) -> String { - const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut out = String::new(); - for chunk in input.chunks(3) { - let b0 = chunk[0] as u32; - let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; - let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; - let n = (b0 << 16) | (b1 << 8) | b2; - out.push(CHARS[((n >> 18) & 63) as usize] as char); - out.push(CHARS[((n >> 12) & 63) as usize] as char); - if chunk.len() > 1 { - out.push(CHARS[((n >> 6) & 63) as usize] as char); - } - if chunk.len() > 2 { - out.push(CHARS[(n & 63) as usize] as char); - } - } - out -} - -fn base64url_decode(input: &str) -> Option> { - // Pad if needed - let mut s = input.replace('-', "+").replace('_', "/"); - while s.len() % 4 != 0 { - s.push('='); - } - base64_decode_standard(&s) -} - -fn base64_decode_standard(input: &str) -> Option> { - const TABLE: [u8; 128] = { - let mut t = [255u8; 128]; - let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut i = 0usize; - while i < chars.len() { - t[chars[i] as usize] = i as u8; - i += 1; - } - t - }; - - let input = input.trim_end_matches('='); - let mut out = Vec::new(); - let bytes = input.as_bytes(); - let mut i = 0; - while i + 3 < bytes.len() { - let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; - let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; - let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?; - let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?; - let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32); - out.push((n >> 16) as u8); - out.push((n >> 8) as u8); - out.push(n as u8); - i += 4; - } - // Handle remaining bytes - if i + 2 == bytes.len() { - let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; - let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; - out.push(((a as u32) << 2 | (b as u32) >> 4) as u8); - } else if i + 3 == bytes.len() { - let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; - let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; - let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?; - let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2); - out.push((n >> 8) as u8); - out.push(n as u8); - } - Some(out) -} - -fn constant_time_eq(a: &str, b: &str) -> bool { - if a.len() != b.len() { - return false; - } - a.bytes() - .zip(b.bytes()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 -} - -fn unix_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -// ── Minimal JSON field extractors ───────────────────────────────────────────── - -fn extract_str(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\":\"", key); - let start = json.find(&pattern)? + pattern.len(); - let rest = &json[start..]; - let end = rest.find('"')?; - Some(rest[..end].to_string()) -} - -fn extract_u64(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\":", key); - let start = json.find(&pattern)? + pattern.len(); - let rest = &json[start..]; - let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); - rest[..end].parse().ok() -} - -fn extract_str_array(json: &str, key: &str) -> Vec { - let pattern = format!("\"{}\":[", key); - let start = match json.find(&pattern) { - None => return Vec::new(), - Some(s) => s + pattern.len(), - }; - let rest = &json[start..]; - let end = rest.find(']').unwrap_or(rest.len()); - let content = &rest[..end]; - content - .split(',') - .filter_map(|s| { - let s = s.trim().trim_matches('"'); - if s.is_empty() { None } else { Some(s.to_string()) } - }) - .collect() -} diff --git a/vessels/el-auth/src/lib.rs b/vessels/el-auth/src/lib.rs deleted file mode 100644 index 3411065..0000000 --- a/vessels/el-auth/src/lib.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! el-auth — Built-in authentication and authorization for el-ui. -//! -//! Not a library you add. Native to the framework. -//! -//! ```toml -//! [auth] -//! provider = "jwt" -//! jwt_secret_env = "JWT_SECRET" -//! session_store = "memory" # or "engram" -//! ``` -//! -//! ## Engram-native sessions -//! -//! Use `EngramSessionStore` (in `engram_session`) for sessions backed by the -//! Engram identity graph. Sessions are graph nodes — server-side invalidation -//! works even with stateless JWTs. - -pub mod context; -pub mod engram_session; -pub mod jwt; -pub mod middleware; -pub mod roles; -pub mod session; - -pub use context::{AuthContext, AuthUser}; -pub use engram_session::EngramSessionStore; -pub use jwt::{JwtClaims, JwtProvider}; -pub use middleware::AuthMiddleware; -pub use roles::{Permission, Role, RoleRegistry}; -pub use session::SessionProvider; - -#[cfg(test)] -mod tests; - -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum AuthError { - #[error("invalid credentials")] - InvalidCredentials, - #[error("token expired")] - TokenExpired, - #[error("token invalid: {0}")] - TokenInvalid(String), - #[error("session not found")] - SessionNotFound, - #[error("forbidden: requires permission '{0}'")] - Forbidden(String), - #[error("auth configuration error: {0}")] - Config(String), -} - -pub type AuthResult = Result; - -/// The AuthProvider trait — implemented by JWT, Session, OAuth providers. -pub trait AuthProvider: Send + Sync { - /// The provider name (e.g., "jwt", "session"). - fn name(&self) -> &'static str; - - /// Verify a token/session string and return the auth context. - fn verify(&self, token: &str) -> AuthResult; - - /// Issue a new token/session for an authenticated user. - fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult; - - /// Revoke a token/session (for logout). - fn revoke(&self, token: &str) -> AuthResult<()>; -} diff --git a/vessels/el-auth/src/middleware.rs b/vessels/el-auth/src/middleware.rs deleted file mode 100644 index a67a530..0000000 --- a/vessels/el-auth/src/middleware.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Auth middleware — extracts and verifies auth tokens from requests. -//! -//! In an axum application: -//! ```text -//! let app = Router::new() -//! .route("/api/users", get(list_users)) -//! .layer(AuthMiddleware::new(jwt_provider)); -//! ``` -//! -//! The middleware populates `AuthContext` from the `Authorization` header. - -use crate::{AuthContext, AuthProvider, AuthResult}; -use std::sync::Arc; - -/// Auth middleware — wraps an auth provider to extract context from HTTP headers. -pub struct AuthMiddleware { - provider: Arc, -} - -impl AuthMiddleware { - pub fn new(provider: Arc) -> Self { - Self { provider } - } - - /// Extract and verify the auth token from an Authorization header value. - /// - /// Supported formats: - /// - `Bearer ` — JWT or opaque token - /// - `Session ` — server-side session - pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult { - match authorization { - None => Ok(AuthContext::anonymous()), - Some(header) => { - let token = if let Some(t) = header.strip_prefix("Bearer ") { - t.trim() - } else if let Some(t) = header.strip_prefix("Session ") { - t.trim() - } else { - header.trim() - }; - self.provider.verify(token) - } - } - } - - /// Authenticate from a query parameter (for WebSocket upgrades where - /// Authorization headers can't be set from JS). - pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult { - match token { - None => Ok(AuthContext::anonymous()), - Some(t) => self.provider.verify(t), - } - } - - /// Get the underlying provider name. - pub fn provider_name(&self) -> &'static str { - self.provider.name() - } -} diff --git a/vessels/el-auth/src/roles.rs b/vessels/el-auth/src/roles.rs deleted file mode 100644 index 041e06c..0000000 --- a/vessels/el-auth/src/roles.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Role and permission model. - -use std::collections::HashMap; - -/// A fine-grained permission (e.g., "read", "write", "delete"). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Permission(pub String); - -impl Permission { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl std::fmt::Display for Permission { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -/// A role that grants a set of permissions. -#[derive(Debug, Clone)] -pub struct Role { - pub name: String, - pub permissions: Vec, -} - -impl Role { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - permissions: Vec::new(), - } - } - - pub fn with_permission(mut self, perm: impl Into) -> Self { - self.permissions.push(Permission::new(perm)); - self - } - - pub fn with_permissions(mut self, perms: Vec>) -> Self { - self.permissions.extend(perms.into_iter().map(Permission::new)); - self - } - - pub fn has_permission(&self, perm: &str) -> bool { - self.permissions.iter().any(|p| p.0 == perm) - } -} - -/// The registry of all roles in the application. -#[derive(Debug, Default)] -pub struct RoleRegistry { - roles: HashMap, -} - -impl RoleRegistry { - pub fn new() -> Self { - Self::default() - } - - /// Register a role. - pub fn register(&mut self, role: Role) { - self.roles.insert(role.name.clone(), role); - } - - /// Get a role by name. - pub fn get(&self, name: &str) -> Option<&Role> { - self.roles.get(name) - } - - /// Check if the given role names grant the given permission. - pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool { - role_names.iter().any(|role_name| { - self.roles - .get(role_name) - .map(|r| r.has_permission(permission)) - .unwrap_or(false) - }) - } - - /// List all registered role names. - pub fn role_names(&self) -> Vec<&str> { - self.roles.keys().map(|s| s.as_str()).collect() - } -} diff --git a/vessels/el-auth/src/session.rs b/vessels/el-auth/src/session.rs deleted file mode 100644 index cc35eb6..0000000 --- a/vessels/el-auth/src/session.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Session provider — server-side sessions stored in memory. -//! -//! In production, sessions are stored in Redis or Engram (configured via -//! `session_store = "redis"` or `session_store = "engram"` in `manifest.el`). -//! This implementation uses in-memory storage for simplicity and testing. - -use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry}; -use std::{ - collections::HashMap, - sync::Mutex, - time::{Duration, Instant}, -}; - -struct SessionEntry { - context: AuthContext, - created_at: Instant, - ttl: Duration, -} - -impl SessionEntry { - fn is_expired(&self) -> bool { - self.created_at.elapsed() > self.ttl - } -} - -/// In-memory session store. -pub struct SessionProvider { - sessions: Mutex>, - pub ttl: Duration, -} - -impl SessionProvider { - pub fn new() -> Self { - Self { - sessions: Mutex::new(HashMap::new()), - ttl: Duration::from_secs(3600), - } - } - - pub fn with_ttl(mut self, seconds: u64) -> Self { - self.ttl = Duration::from_secs(seconds); - self - } - - fn generate_session_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890) - } - - /// Count active (non-expired) sessions. - pub fn active_session_count(&self) -> usize { - let sessions = self.sessions.lock().expect("session lock poisoned"); - sessions.values().filter(|s| !s.is_expired()).count() - } -} - -impl Default for SessionProvider { - fn default() -> Self { - Self::new() - } -} - -impl AuthProvider for SessionProvider { - fn name(&self) -> &'static str { - "session" - } - - fn verify(&self, session_id: &str) -> AuthResult { - let mut sessions = self.sessions.lock().expect("session lock poisoned"); - // Clean expired sessions - sessions.retain(|_, v| !v.is_expired()); - sessions - .get(session_id) - .filter(|s| !s.is_expired()) - .map(|s| s.context.clone()) - .ok_or(AuthError::SessionNotFound) - } - - fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult { - let session_id = Self::generate_session_id(); - let ctx = AuthContext::authenticated(user, Vec::new(), &session_id); - let entry = SessionEntry { - context: ctx, - created_at: Instant::now(), - ttl: self.ttl, - }; - self.sessions - .lock() - .expect("session lock poisoned") - .insert(session_id.clone(), entry); - Ok(session_id) - } - - fn revoke(&self, session_id: &str) -> AuthResult<()> { - self.sessions - .lock() - .expect("session lock poisoned") - .remove(session_id); - Ok(()) - } -} diff --git a/vessels/el-auth/src/tests.rs b/vessels/el-auth/src/tests.rs deleted file mode 100644 index c14a4e4..0000000 --- a/vessels/el-auth/src/tests.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Tests for el-auth. - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use crate::{ - context::{AuthContext, AuthUser}, - jwt::{JwtClaims, JwtProvider}, - middleware::AuthMiddleware, - roles::{Permission, Role, RoleRegistry}, - session::SessionProvider, - AuthError, AuthProvider, - }; - - fn test_user() -> AuthUser { - AuthUser::new("user-1", "alice@example.com", "Alice") - } - - fn test_provider() -> JwtProvider { - JwtProvider::new(b"super-secret-key-for-testing-only".to_vec()) - .with_ttl(3600) - } - - fn registry() -> RoleRegistry { - let mut r = RoleRegistry::new(); - r.register( - Role::new("admin") - .with_permissions(vec!["read", "write", "delete"]), - ); - r.register( - Role::new("user") - .with_permissions(vec!["read"]), - ); - r - } - - // ── Test 1: JWT round-trip — sign and verify ────────────────────────────── - #[test] - fn test_jwt_sign_and_verify() { - let provider = test_provider(); - let user = test_user(); - let claims = JwtClaims::new(&user, vec!["user".into()], 3600); - let token = provider.encode(&claims); - let decoded = provider.decode(&token).unwrap(); - assert_eq!(decoded.sub, "user-1"); - assert_eq!(decoded.email, "alice@example.com"); - assert_eq!(decoded.roles, vec!["user"]); - } - - // ── Test 2: JWT invalid signature is rejected ───────────────────────────── - #[test] - fn test_jwt_invalid_signature() { - let provider = test_provider(); - let other_provider = JwtProvider::new(b"different-secret".to_vec()); - let user = test_user(); - let claims = JwtClaims::new(&user, vec![], 3600); - let token = other_provider.encode(&claims); - let result = provider.decode(&token); - assert!(matches!(result, Err(AuthError::TokenInvalid(_)))); - } - - // ── Test 3: JWT expired token is rejected ──────────────────────────────── - #[test] - fn test_jwt_expired_token() { - let provider = test_provider(); - let user = test_user(); - // TTL of 0 — expires immediately - let claims = JwtClaims::new(&user, vec![], 0); - let token = provider.encode(&claims); - // Wait a moment (in tests, just check the claims are_expired) - assert!(claims.is_expired() || { - std::thread::sleep(std::time::Duration::from_millis(1100)); - true - }); - let result = provider.decode(&token); - assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_)))); - } - - // ── Test 4: JWT verify returns correct AuthContext ─────────────────────── - #[test] - fn test_jwt_verify_returns_auth_context() { - let provider = test_provider(); - let user = test_user(); - let reg = registry(); - let token = provider.issue(user, ®).unwrap(); - let ctx = provider.verify(&token).unwrap(); - assert!(ctx.is_authenticated()); - assert_eq!(ctx.user_id().unwrap(), "user-1"); - } - - // ── Test 5: AuthContext::has_role works ────────────────────────────────── - #[test] - fn test_auth_context_has_role() { - let ctx = AuthContext::authenticated( - test_user(), - vec!["admin".into(), "user".into()], - "tok", - ); - assert!(ctx.has_role("admin")); - assert!(ctx.has_role("user")); - assert!(!ctx.has_role("superadmin")); - } - - // ── Test 6: AuthContext::has_permission works ───────────────────────────── - #[test] - fn test_auth_context_has_permission() { - let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok") - .with_permissions(vec!["read".into(), "write".into(), "delete".into()]); - assert!(ctx.has_permission("read")); - assert!(ctx.has_permission("delete")); - assert!(!ctx.has_permission("sudo")); - } - - // ── Test 7: AuthContext::anonymous is not authenticated ─────────────────── - #[test] - fn test_anonymous_context() { - let ctx = AuthContext::anonymous(); - assert!(!ctx.is_authenticated()); - assert!(ctx.user_id().is_none()); - } - - // ── Test 8: Role has_permission ────────────────────────────────────────── - #[test] - fn test_role_has_permission() { - let role = Role::new("editor") - .with_permissions(vec!["read", "write"]); - assert!(role.has_permission("read")); - assert!(role.has_permission("write")); - assert!(!role.has_permission("delete")); - } - - // ── Test 9: RoleRegistry::has_permission checks across roles ───────────── - #[test] - fn test_role_registry_permission_check() { - let reg = registry(); - let roles = vec!["user".to_string()]; - assert!(reg.has_permission(&roles, "read")); - assert!(!reg.has_permission(&roles, "delete")); - - let admin_roles = vec!["admin".to_string()]; - assert!(reg.has_permission(&admin_roles, "delete")); - } - - // ── Test 10: SessionProvider issue and verify ───────────────────────────── - #[test] - fn test_session_issue_and_verify() { - let provider = SessionProvider::new(); - let reg = registry(); - let session_id = provider.issue(test_user(), ®).unwrap(); - let ctx = provider.verify(&session_id).unwrap(); - assert!(ctx.is_authenticated()); - assert_eq!(ctx.user_id().unwrap(), "user-1"); - } - - // ── Test 11: SessionProvider revoke removes session ─────────────────────── - #[test] - fn test_session_revoke() { - let provider = SessionProvider::new(); - let reg = registry(); - let session_id = provider.issue(test_user(), ®).unwrap(); - provider.revoke(&session_id).unwrap(); - let result = provider.verify(&session_id); - assert!(matches!(result, Err(AuthError::SessionNotFound))); - } - - // ── Test 12: SessionProvider unknown session returns error ──────────────── - #[test] - fn test_session_unknown() { - let provider = SessionProvider::new(); - let result = provider.verify("nonexistent-session-id"); - assert!(matches!(result, Err(AuthError::SessionNotFound))); - } - - // ── Test 13: AuthMiddleware extracts Bearer token ───────────────────────── - #[test] - fn test_middleware_extracts_bearer_token() { - let provider = Arc::new(test_provider()); - let user = test_user(); - let claims = JwtClaims::new(&user, vec!["user".into()], 3600); - let token = provider.encode(&claims); - let middleware = AuthMiddleware::new(provider); - let header = format!("Bearer {}", token); - let ctx = middleware.authenticate_from_header(Some(&header)).unwrap(); - assert!(ctx.is_authenticated()); - } - - // ── Test 14: AuthMiddleware with no header returns anonymous ────────────── - #[test] - fn test_middleware_no_header_anonymous() { - let provider = Arc::new(test_provider()); - let middleware = AuthMiddleware::new(provider); - let ctx = middleware.authenticate_from_header(None).unwrap(); - assert!(!ctx.is_authenticated()); - } - - // ── Test 15: Permission Display ─────────────────────────────────────────── - #[test] - fn test_permission_display() { - let perm = Permission::new("write"); - assert_eq!(perm.to_string(), "write"); - assert_eq!(perm.as_str(), "write"); - } - - // ── Test 16: JwtClaims::to_json and from_json round-trip ───────────────── - #[test] - fn test_jwt_claims_json_round_trip() { - let user = test_user(); - let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600); - let json = claims.to_json(); - let decoded = JwtClaims::from_json(&json).unwrap(); - assert_eq!(decoded.sub, "user-1"); - assert_eq!(decoded.email, "alice@example.com"); - assert_eq!(decoded.roles, vec!["admin", "user"]); - } - - // ── Test 17: JWT with multiple roles ───────────────────────────────────── - #[test] - fn test_jwt_multiple_roles() { - let provider = test_provider(); - let user = test_user(); - let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600); - let token = provider.encode(&claims); - let decoded = provider.decode(&token).unwrap(); - assert_eq!(decoded.roles.len(), 2); - assert!(decoded.roles.contains(&"admin".to_string())); - } - - // ── Test 18: RoleRegistry::role_names lists all roles ──────────────────── - #[test] - fn test_role_registry_names() { - let reg = registry(); - let mut names = reg.role_names(); - names.sort(); - assert_eq!(names, vec!["admin", "user"]); - } - - // ── Test 19: SessionProvider TTL configuration ──────────────────────────── - #[test] - fn test_session_ttl_config() { - let provider = SessionProvider::new().with_ttl(7200); - assert_eq!(provider.ttl.as_secs(), 7200); - } - - // ── Test 20: AuthMiddleware provider_name returns correct name ──────────── - #[test] - fn test_middleware_provider_name() { - let jwt_provider = Arc::new(test_provider()); - let middleware = AuthMiddleware::new(jwt_provider); - assert_eq!(middleware.provider_name(), "jwt"); - } -} diff --git a/vessels/el-config/Cargo.toml b/vessels/el-config/Cargo.toml deleted file mode 100644 index b7e40b0..0000000 --- a/vessels/el-config/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "el-config" -version = "0.1.0" -edition = "2021" -description = "el-ui configuration system — layered, typed, environment-aware" -license = "MIT" - -[lib] -name = "el_config" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" - -[dev-dependencies] diff --git a/vessels/el-config/src/config.rs b/vessels/el-config/src/config.rs deleted file mode 100644 index 13c0c42..0000000 --- a/vessels/el-config/src/config.rs +++ /dev/null @@ -1,337 +0,0 @@ -/// Config — layered configuration with typed access. -/// -/// Sources are stacked in priority order. The first source to provide a value -/// for a key wins. Resolution order: -/// 1. Environment variables (highest) -/// 2. .env file (dev only) -/// 3. manifest.el [env.] section -/// 4. manifest.el [config] section (base) -/// 5. Defaults defined in code (lowest) - -use std::collections::HashMap; -use crate::error::ConfigError; -use crate::source::{ConfigSource, FromConfigStr, MapSource}; -use crate::env::Environment; - -/// The main configuration object. -/// -/// Holds a stack of sources and resolves keys through them in order. -pub struct Config { - /// Sources in descending priority order (index 0 = highest priority). - sources: Vec>, - /// Environment in effect. - pub environment: Environment, -} - -impl Config { - /// Create an empty config with no sources. - pub fn new(env: Environment) -> Self { - Self { - sources: Vec::new(), - environment: env, - } - } - - /// Build with the default source stack for an application: - /// env vars > defaults map. - pub fn default_stack() -> Self { - use crate::source::EnvVarSource; - let env = Environment::current(); - let mut config = Self::new(env); - config.push_source(Box::new(EnvVarSource::new())); - config - } - - /// Add a source at the lowest priority (end of the stack). - pub fn push_source(&mut self, source: Box) { - self.sources.push(source); - } - - /// Add a source at the highest priority (beginning of the stack). - pub fn prepend_source(&mut self, source: Box) { - self.sources.insert(0, source); - } - - /// Add defaults as the lowest-priority source. - pub fn set_defaults(&mut self, defaults: HashMap) { - let src = MapSource::from_map("defaults", defaults); - self.sources.push(Box::new(src)); - } - - /// Get a raw string value for a key. - pub fn get_raw(&self, key: &str) -> Option { - for source in &self.sources { - if let Some(val) = source.get_raw(key) { - return Some(val); - } - } - None - } - - /// Get a typed value for a key. - /// - /// Returns an error if the key is not found or can't be parsed. - pub fn get(&self, key: &str) -> Result { - let raw = self.get_raw(key).ok_or_else(|| ConfigError::NotFound { - key: key.to_string(), - })?; - - T::from_config_str(&raw).map_err(|e| { - // Inject the key into TypeMismatch errors - match e { - ConfigError::TypeMismatch { expected, got, .. } => { - ConfigError::TypeMismatch { - key: key.to_string(), - expected, - got, - } - } - other => other, - } - }) - } - - /// Get a typed value with a fallback default. - pub fn get_or(&self, key: &str, default: T) -> T { - self.get(key).unwrap_or(default) - } - - /// Get an optional typed value. Returns None if not set (not an error). - pub fn get_opt(&self, key: &str) -> Result, ConfigError> { - match self.get_raw(key) { - None => Ok(None), - Some(raw) => T::from_config_str(&raw) - .map(Some) - .map_err(|e| match e { - ConfigError::TypeMismatch { expected, got, .. } => { - ConfigError::TypeMismatch { - key: key.to_string(), - expected, - got, - } - } - other => other, - }), - } - } - - /// All key→value pairs from all sources (merged, highest-priority wins). - pub fn all(&self) -> HashMap { - let mut result = HashMap::new(); - // Iterate in reverse order (lowest priority first) so higher-priority - // sources overwrite lower-priority ones. - for source in self.sources.iter().rev() { - for (k, v) in source.all() { - result.insert(k, v); - } - } - result - } -} - -/// Load config from an `manifest.el` string. -/// -/// Reads `[config]` as the base, then overlays `[env.]`. -pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result { - let value: toml::Value = toml::from_str(toml_str) - .map_err(|e| ConfigError::ParseError(e.to_string()))?; - - let mut map = HashMap::new(); - - // Load base [config] section - if let Some(config_section) = value.get("config") { - if let Some(table) = config_section.as_table() { - flatten_toml_table(table, "", &mut map); - } - } - - // Overlay [env.] section - let env_key = env.name(); - if let Some(env_sections) = value.get("env") { - if let Some(env_table) = env_sections.get(env_key) { - if let Some(table) = env_table.as_table() { - flatten_toml_table(table, "", &mut map); - } - } - } - - Ok(MapSource::from_map("manifest.el", map)) -} - -fn flatten_toml_table( - table: &toml::value::Table, - prefix: &str, - out: &mut HashMap, -) { - for (key, value) in table { - let full_key = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - - match value { - toml::Value::String(s) => { - out.insert(full_key, s.clone()); - } - toml::Value::Integer(i) => { - out.insert(full_key, i.to_string()); - } - toml::Value::Float(f) => { - out.insert(full_key, f.to_string()); - } - toml::Value::Boolean(b) => { - out.insert(full_key, b.to_string()); - } - toml::Value::Table(t) => { - flatten_toml_table(t, &full_key, out); - } - _ => {} // Arrays, datetimes: skip for now - } - } -} - -/// Macro for typed config access on a global/injected Config. -/// -/// ```ignore -/// let name = config!(cfg, "app.name", String); -/// let port = config!(cfg, "server.port", u32, 8080); -/// ``` -#[macro_export] -macro_rules! config { - ($cfg:expr, $key:expr, $type:ty) => { - $cfg.get::<$type>($key) - }; - ($cfg:expr, $key:expr, $type:ty, $default:expr) => { - $cfg.get_or::<$type>($key, $default) - }; -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::source::MapSource; - - fn make_config(pairs: &[(&str, &str)]) -> Config { - let mut src = MapSource::new("test"); - for (k, v) in pairs { - src.insert(*k, *v); - } - let mut cfg = Config::new(Environment::Development); - cfg.push_source(Box::new(src)); - cfg - } - - #[test] - fn get_string() { - let cfg = make_config(&[("app.name", "TestApp")]); - assert_eq!(cfg.get::("app.name").unwrap(), "TestApp"); - } - - #[test] - fn get_u32() { - let cfg = make_config(&[("server.port", "8080")]); - assert_eq!(cfg.get::("server.port").unwrap(), 8080u32); - } - - #[test] - fn get_bool() { - let cfg = make_config(&[("feature.enabled", "true")]); - assert_eq!(cfg.get::("feature.enabled").unwrap(), true); - } - - #[test] - fn get_missing_returns_error() { - let cfg = Config::new(Environment::Development); - assert!(cfg.get::("missing.key").is_err()); - } - - #[test] - fn get_or_default() { - let cfg = Config::new(Environment::Development); - assert_eq!(cfg.get_or("timeout", 30u32), 30u32); - } - - #[test] - fn get_or_prefers_source() { - let cfg = make_config(&[("timeout", "60")]); - assert_eq!(cfg.get_or("timeout", 30u32), 60u32); - } - - #[test] - fn higher_priority_source_wins() { - let mut cfg = Config::new(Environment::Development); - let mut low = MapSource::new("low"); - low.insert("key", "low-value"); - let mut high = MapSource::new("high"); - high.insert("key", "high-value"); - cfg.push_source(Box::new(high)); - cfg.push_source(Box::new(low)); - // First source (index 0) is highest priority - assert_eq!(cfg.get::("key").unwrap(), "high-value"); - } - - #[test] - fn get_opt_missing_is_none() { - let cfg = Config::new(Environment::Development); - assert_eq!(cfg.get_opt::("missing").unwrap(), None); - } - - #[test] - fn get_opt_present_is_some() { - let cfg = make_config(&[("key", "value")]); - assert_eq!( - cfg.get_opt::("key").unwrap(), - Some("value".to_string()) - ); - } - - #[test] - fn load_from_toml_base() { - let toml = r#" -[config] -app.name = "MyApp" -app.version = "1.0.0" -"#; - let src = load_from_toml(toml, &Environment::Development).unwrap(); - assert_eq!(src.get_raw("app.name"), Some("MyApp".to_string())); - } - - #[test] - fn load_from_toml_env_overlay() { - let toml = r#" -[config] -api.base_url = "https://api.example.com" - -[env.development] -api.base_url = "http://localhost:8080" -"#; - let src = load_from_toml(toml, &Environment::Development).unwrap(); - assert_eq!( - src.get_raw("api.base_url"), - Some("http://localhost:8080".to_string()) - ); - } - - #[test] - fn load_from_toml_env_does_not_override_in_prod() { - let toml = r#" -[config] -api.base_url = "https://api.example.com" - -[env.development] -api.base_url = "http://localhost:8080" -"#; - let src = load_from_toml(toml, &Environment::Production).unwrap(); - assert_eq!( - src.get_raw("api.base_url"), - Some("https://api.example.com".to_string()) - ); - } - - #[test] - fn load_from_toml_invalid() { - let result = load_from_toml("not valid toml %%%", &Environment::Development); - assert!(result.is_err()); - } -} diff --git a/vessels/el-config/src/env.rs b/vessels/el-config/src/env.rs deleted file mode 100644 index dab5601..0000000 --- a/vessels/el-config/src/env.rs +++ /dev/null @@ -1,114 +0,0 @@ -/// Environment detection — which deployment context are we in? - -/// The current deployment environment. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Environment { - /// Local developer machine. Verbose errors, hot reload, relaxed auth. - Development, - /// Pre-production environment. Production build, test data. - Staging, - /// Live production. Minimal logging, strict auth, performance mode. - Production, -} - -impl Environment { - /// Detect from the `EL_ENV` environment variable (or `APP_ENV`, `RUST_ENV`). - /// - /// Falls back to Development if unset or unrecognized. - pub fn current() -> Self { - let val = std::env::var("EL_ENV") - .or_else(|_| std::env::var("APP_ENV")) - .or_else(|_| std::env::var("RUST_ENV")) - .unwrap_or_default(); - - Self::from_str(&val) - } - - /// Parse from a string. - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "production" | "prod" => Environment::Production, - "staging" | "stage" => Environment::Staging, - _ => Environment::Development, - } - } - - /// The canonical name for this environment. - pub fn name(&self) -> &'static str { - match self { - Environment::Development => "development", - Environment::Staging => "staging", - Environment::Production => "production", - } - } - - /// Whether this is a production environment. - pub fn is_production(&self) -> bool { - matches!(self, Environment::Production) - } - - /// Whether this is a development environment. - pub fn is_development(&self) -> bool { - matches!(self, Environment::Development) - } - - /// Whether debug features should be enabled. - pub fn debug_enabled(&self) -> bool { - !self.is_production() - } -} - -impl std::fmt::Display for Environment { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_production() { - assert_eq!(Environment::from_str("production"), Environment::Production); - assert_eq!(Environment::from_str("prod"), Environment::Production); - assert_eq!(Environment::from_str("PRODUCTION"), Environment::Production); - } - - #[test] - fn parse_staging() { - assert_eq!(Environment::from_str("staging"), Environment::Staging); - assert_eq!(Environment::from_str("stage"), Environment::Staging); - } - - #[test] - fn parse_development_fallback() { - assert_eq!(Environment::from_str("dev"), Environment::Development); - assert_eq!(Environment::from_str(""), Environment::Development); - assert_eq!(Environment::from_str("unknown"), Environment::Development); - } - - #[test] - fn production_is_production() { - assert!(Environment::Production.is_production()); - assert!(!Environment::Development.is_production()); - } - - #[test] - fn development_debug_enabled() { - assert!(Environment::Development.debug_enabled()); - assert!(!Environment::Production.debug_enabled()); - } - - #[test] - fn environment_name() { - assert_eq!(Environment::Production.name(), "production"); - assert_eq!(Environment::Staging.name(), "staging"); - assert_eq!(Environment::Development.name(), "development"); - } - - #[test] - fn display() { - assert_eq!(format!("{}", Environment::Production), "production"); - } -} diff --git a/vessels/el-config/src/error.rs b/vessels/el-config/src/error.rs deleted file mode 100644 index ed18a23..0000000 --- a/vessels/el-config/src/error.rs +++ /dev/null @@ -1,20 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ConfigError { - #[error("config key '{key}' not found")] - NotFound { key: String }, - - #[error("config key '{key}': expected {expected}, got '{got}'")] - TypeMismatch { - key: String, - expected: String, - got: String, - }, - - #[error("config parse error: {0}")] - ParseError(String), - - #[error("config source error '{source_name}': {message}")] - SourceError { source_name: String, message: String }, -} diff --git a/vessels/el-config/src/lib.rs b/vessels/el-config/src/lib.rs deleted file mode 100644 index fbd9b6f..0000000 --- a/vessels/el-config/src/lib.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! el-config — Layered, typed configuration for el-ui applications. -//! -//! ## Resolution order (highest priority wins) -//! -//! 1. Environment variables (`EL_APP_NAME=...`) -//! 2. `.env` file (development only) -//! 3. `manifest.el` `[env.]` section -//! 4. `manifest.el` `[config]` base section -//! 5. Defaults defined in code -//! -//! ## Quick start -//! -//! ``` -//! use el_config::prelude::*; -//! -//! let mut cfg = Config::new(Environment::Development); -//! let mut defaults = std::collections::HashMap::new(); -//! defaults.insert("app.name".to_string(), "MyApp".to_string()); -//! defaults.insert("server.port".to_string(), "8080".to_string()); -//! cfg.set_defaults(defaults); -//! -//! let name = cfg.get::("app.name").unwrap(); -//! let port = cfg.get::("server.port").unwrap(); -//! assert_eq!(name, "MyApp"); -//! assert_eq!(port, 8080); -//! ``` - -#![deny(warnings)] - -pub mod config; -pub mod env; -pub mod error; -pub mod source; - -pub mod prelude { - pub use crate::config::{load_from_toml, Config}; - pub use crate::env::Environment; - pub use crate::error::ConfigError; - pub use crate::source::{ConfigSource, EnvVarSource, FromConfigStr, MapSource}; -} - -pub use prelude::*; diff --git a/vessels/el-config/src/source.rs b/vessels/el-config/src/source.rs deleted file mode 100644 index 6d7cb43..0000000 --- a/vessels/el-config/src/source.rs +++ /dev/null @@ -1,248 +0,0 @@ -/// ConfigSource trait and implementations. -/// -/// Each source provides key→value pairs. Sources are stacked in priority order; -/// the Config struct resolves by asking each source in turn. - -use std::collections::HashMap; -use crate::error::ConfigError; - -/// A source of configuration values. -pub trait ConfigSource: Send + Sync { - /// The name of this source (for debugging/error messages). - fn name(&self) -> &str; - - /// Get a raw string value for a key. - /// Returns None if this source doesn't have the key. - fn get_raw(&self, key: &str) -> Option; - - /// All key→value pairs from this source. - fn all(&self) -> HashMap; -} - -/// Reads from environment variables. -/// -/// Keys are mapped: `app.name` → `EL_APP_NAME` (uppercased, dots → underscores). -pub struct EnvVarSource { - /// Optional prefix. Default: "EL". - prefix: String, -} - -impl EnvVarSource { - pub fn new() -> Self { - Self { prefix: "EL".to_string() } - } - - pub fn with_prefix(prefix: impl Into) -> Self { - Self { prefix: prefix.into() } - } - - fn env_key(&self, key: &str) -> String { - let normalized = key.replace('.', "_").replace('-', "_").to_uppercase(); - format!("{}_{}", self.prefix, normalized) - } -} - -impl Default for EnvVarSource { - fn default() -> Self { - Self::new() - } -} - -impl ConfigSource for EnvVarSource { - fn name(&self) -> &str { - "environment" - } - - fn get_raw(&self, key: &str) -> Option { - std::env::var(self.env_key(key)).ok() - } - - fn all(&self) -> HashMap { - let prefix = format!("{}_", self.prefix); - std::env::vars() - .filter(|(k, _)| k.starts_with(&prefix)) - .map(|(k, v)| { - let stripped = k.strip_prefix(&prefix).unwrap_or(&k); - let config_key = stripped.to_lowercase().replace('_', "."); - (config_key, v) - }) - .collect() - } -} - -/// Holds an in-memory map of config values. -/// -/// Used for defaults defined in code, or for config loaded from a parsed -/// TOML/JSON file section. -pub struct MapSource { - name: String, - values: HashMap, -} - -impl MapSource { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - values: HashMap::new(), - } - } - - pub fn insert(&mut self, key: impl Into, value: impl Into) { - self.values.insert(key.into(), value.into()); - } - - pub fn from_map(name: impl Into, map: HashMap) -> Self { - Self { - name: name.into(), - values: map, - } - } -} - -impl ConfigSource for MapSource { - fn name(&self) -> &str { - &self.name - } - - fn get_raw(&self, key: &str) -> Option { - self.values.get(key).cloned() - } - - fn all(&self) -> HashMap { - self.values.clone() - } -} - -/// Typed config value extractor. -pub trait FromConfigStr: Sized { - fn from_config_str(s: &str) -> Result; -} - -impl FromConfigStr for String { - fn from_config_str(s: &str) -> Result { - Ok(s.to_string()) - } -} - -impl FromConfigStr for u32 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "u32".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for u64 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "u64".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for i32 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "i32".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for i64 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "i64".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for f32 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "f32".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for f64 { - fn from_config_str(s: &str) -> Result { - s.parse().map_err(|_| ConfigError::TypeMismatch { - key: String::new(), - expected: "f64".to_string(), - got: s.to_string(), - }) - } -} - -impl FromConfigStr for bool { - fn from_config_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "true" | "1" | "yes" | "on" => Ok(true), - "false" | "0" | "no" | "off" => Ok(false), - _ => Err(ConfigError::TypeMismatch { - key: String::new(), - expected: "bool".to_string(), - got: s.to_string(), - }), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn map_source_get() { - let mut src = MapSource::new("test"); - src.insert("app.name", "TestApp"); - assert_eq!(src.get_raw("app.name"), Some("TestApp".to_string())); - } - - #[test] - fn map_source_missing() { - let src = MapSource::new("test"); - assert_eq!(src.get_raw("no.key"), None); - } - - #[test] - fn bool_from_config_str() { - assert_eq!(bool::from_config_str("true").unwrap(), true); - assert_eq!(bool::from_config_str("1").unwrap(), true); - assert_eq!(bool::from_config_str("false").unwrap(), false); - assert_eq!(bool::from_config_str("0").unwrap(), false); - } - - #[test] - fn u32_from_config_str() { - assert_eq!(u32::from_config_str("42").unwrap(), 42u32); - } - - #[test] - fn u32_type_mismatch() { - assert!(u32::from_config_str("not-a-number").is_err()); - } - - #[test] - fn env_key_mapping() { - let src = EnvVarSource::new(); - // app.name → EL_APP_NAME - assert_eq!(src.env_key("app.name"), "EL_APP_NAME"); - } - - #[test] - fn env_source_name() { - let src = EnvVarSource::new(); - assert_eq!(src.name(), "environment"); - } -} diff --git a/vessels/el-i18n/Cargo.toml b/vessels/el-i18n/Cargo.toml deleted file mode 100644 index 540b909..0000000 --- a/vessels/el-i18n/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "el-i18n" -version = "0.1.0" -edition = "2021" -description = "el-ui localization — RTL-aware, plural forms, CLDR-based formatting" -license = "MIT" - -[lib] -name = "el_i18n" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" - -[dev-dependencies] diff --git a/vessels/el-i18n/src/bundle.rs b/vessels/el-i18n/src/bundle.rs deleted file mode 100644 index ee60792..0000000 --- a/vessels/el-i18n/src/bundle.rs +++ /dev/null @@ -1,299 +0,0 @@ -/// LocaleBundle — loads and caches translation strings. -/// -/// A bundle holds all translation strings for one locale. Strings are -/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle -/// supports both flat strings and plural forms. - -use std::collections::HashMap; -use crate::locale::Locale; -use crate::plural::{plural_form, PluralForm}; - -/// A single translation value — either a simple string or a plural map. -#[derive(Debug, Clone)] -pub enum TranslationValue { - /// A simple translated string. May contain `{key}` interpolation placeholders. - Simple(String), - /// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other". - Plural(HashMap), -} - -/// A bundle of translations for a single locale. -#[derive(Debug, Clone)] -pub struct LocaleBundle { - pub locale: Locale, - translations: HashMap, -} - -impl LocaleBundle { - /// Create an empty bundle for a locale. - pub fn new(locale: Locale) -> Self { - Self { - locale, - translations: HashMap::new(), - } - } - - /// Insert a simple translation. - pub fn insert(&mut self, key: impl Into, value: impl Into) { - self.translations.insert( - key.into(), - TranslationValue::Simple(value.into()), - ); - } - - /// Insert a plural translation. - pub fn insert_plural( - &mut self, - key: impl Into, - forms: HashMap, - ) { - self.translations - .insert(key.into(), TranslationValue::Plural(forms)); - } - - /// Look up a key and return the simple string (no interpolation). - pub fn get_raw(&self, key: &str) -> Option<&str> { - match self.translations.get(key)? { - TranslationValue::Simple(s) => Some(s.as_str()), - TranslationValue::Plural(_) => None, - } - } - - /// Look up a key with variable interpolation. - /// - /// Replaces `{name}` placeholders with values from `vars`. - pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option { - let raw = match self.translations.get(key)? { - TranslationValue::Simple(s) => s.clone(), - TranslationValue::Plural(forms) => { - // For translate(), use "other" as default - forms.get("other")?.clone() - } - }; - Some(interpolate(&raw, vars)) - } - - /// Look up a plural key with a count. - /// - /// Selects the correct plural form for the locale's language and count, - /// then interpolates `{n}` and any other `vars`. - pub fn translate_plural( - &self, - key: &str, - count: i64, - vars: &HashMap<&str, String>, - ) -> Option { - let forms = match self.translations.get(key)? { - TranslationValue::Plural(f) => f, - TranslationValue::Simple(s) => { - // Fall through: treat the simple string as "other" - let mut result_vars = vars.clone(); - result_vars.insert("n", count.to_string()); - return Some(interpolate(s, &result_vars)); - } - }; - - let form = plural_form(&self.locale.language, count); - let form_key = match form { - PluralForm::Zero => "zero", - PluralForm::One => "one", - PluralForm::Two => "two", - PluralForm::Few => "few", - PluralForm::Many => "many", - PluralForm::Other => "other", - }; - - let template = forms - .get(form_key) - .or_else(|| forms.get("other"))?; - - let mut result_vars = vars.clone(); - result_vars.insert("n", count.to_string()); - Some(interpolate(template, &result_vars)) - } - - /// Number of translations loaded. - pub fn len(&self) -> usize { - self.translations.len() - } - - pub fn is_empty(&self) -> bool { - self.translations.is_empty() - } -} - -/// Replace `{key}` placeholders in `template` with values from `vars`. -fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String { - let mut result = template.to_string(); - for (key, value) in vars { - result = result.replace(&format!("{{{}}}", key), value); - } - result -} - -/// Load a bundle from a TOML string. -/// -/// Format: -/// ```toml -/// [profile] -/// follow = "Follow" -/// followers = { one = "{n} Follower", other = "{n} Followers" } -/// ``` -pub fn load_toml(locale: Locale, toml_str: &str) -> Result { - let value: toml::Value = toml::from_str(toml_str) - .map_err(|e| format!("TOML parse error: {}", e))?; - - let mut bundle = LocaleBundle::new(locale); - - if let toml::Value::Table(table) = value { - load_table(&mut bundle, &table, ""); - } - - Ok(bundle) -} - -fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) { - for (key, value) in table { - let full_key = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - - match value { - toml::Value::String(s) => { - bundle.insert(full_key, s.clone()); - } - toml::Value::Table(inner) => { - // Check if it's a plural table (has "one", "other", etc.) - let is_plural = inner.contains_key("one") - || inner.contains_key("other") - || inner.contains_key("zero") - || inner.contains_key("few") - || inner.contains_key("many"); - - if is_plural { - let mut forms = HashMap::new(); - for (form, form_val) in inner { - if let toml::Value::String(s) = form_val { - forms.insert(form.clone(), s.clone()); - } - } - bundle.insert_plural(full_key, forms); - } else { - // Nested namespace - load_table(bundle, inner, &full_key); - } - } - _ => {} - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn en_bundle() -> LocaleBundle { - let mut b = LocaleBundle::new(Locale::en_us()); - b.insert("profile.follow", "Follow"); - b.insert("profile.bio", "Bio"); - let mut forms = HashMap::new(); - forms.insert("one".to_string(), "{n} Follower".to_string()); - forms.insert("other".to_string(), "{n} Followers".to_string()); - b.insert_plural("profile.followers", forms); - b - } - - #[test] - fn get_raw_simple() { - let b = en_bundle(); - assert_eq!(b.get_raw("profile.follow"), Some("Follow")); - } - - #[test] - fn get_raw_missing() { - let b = en_bundle(); - assert_eq!(b.get_raw("nonexistent.key"), None); - } - - #[test] - fn translate_simple() { - let b = en_bundle(); - let vars = HashMap::new(); - assert_eq!( - b.translate("profile.follow", &vars), - Some("Follow".to_string()) - ); - } - - #[test] - fn translate_plural_one() { - let b = en_bundle(); - let vars = HashMap::new(); - assert_eq!( - b.translate_plural("profile.followers", 1, &vars), - Some("1 Follower".to_string()) - ); - } - - #[test] - fn translate_plural_many() { - let b = en_bundle(); - let vars = HashMap::new(); - assert_eq!( - b.translate_plural("profile.followers", 42, &vars), - Some("42 Followers".to_string()) - ); - } - - #[test] - fn interpolation_replaces_placeholder() { - let mut b = LocaleBundle::new(Locale::en_us()); - b.insert("greeting", "Hello, {name}!"); - let mut vars = HashMap::new(); - vars.insert("name", "Alice".to_string()); - assert_eq!( - b.translate("greeting", &vars), - Some("Hello, Alice!".to_string()) - ); - } - - #[test] - fn bundle_len() { - let b = en_bundle(); - assert_eq!(b.len(), 3); - } - - #[test] - fn load_toml_simple() { - let toml = r#" -[profile] -follow = "Follow" -bio = "Bio" -"#; - let bundle = load_toml(Locale::en_us(), toml).unwrap(); - assert_eq!(bundle.get_raw("profile.follow"), Some("Follow")); - assert_eq!(bundle.get_raw("profile.bio"), Some("Bio")); - } - - #[test] - fn load_toml_plural() { - let toml = r#" -[profile] -followers = { one = "{n} Follower", other = "{n} Followers" } -"#; - let bundle = load_toml(Locale::en_us(), toml).unwrap(); - let vars = HashMap::new(); - assert_eq!( - bundle.translate_plural("profile.followers", 1, &vars), - Some("1 Follower".to_string()) - ); - } - - #[test] - fn load_toml_invalid() { - let result = load_toml(Locale::en_us(), "not valid toml %%%"); - assert!(result.is_err()); - } -} diff --git a/vessels/el-i18n/src/format.rs b/vessels/el-i18n/src/format.rs deleted file mode 100644 index 865380c..0000000 --- a/vessels/el-i18n/src/format.rs +++ /dev/null @@ -1,221 +0,0 @@ -/// Number, date, and currency formatting per locale. -/// -/// Formatting is locale-sensitive: number grouping, decimal separators, -/// currency symbol placement, and date ordering all vary by locale. -/// Use these formatters rather than hardcoding formatting logic. - -use crate::locale::Locale; - -/// Format a number with locale-appropriate grouping and decimals. -/// -/// Examples: -/// - en-US: 1,234,567.89 -/// - de-DE: 1.234.567,89 -/// - fr-FR: 1 234 567,89 -pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String { - let (group_sep, decimal_sep) = separators_for_locale(locale); - - let rounded = round_to(value, decimal_places); - let is_negative = rounded < 0.0; - let abs_value = rounded.abs(); - - let int_part = abs_value.trunc() as u64; - let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64; - - let int_str = format_integer_with_grouping(int_part, group_sep); - - let result = if decimal_places > 0 { - format!( - "{}{}{}", - int_str, - decimal_sep, - format!("{:0>width$}", frac_part, width = decimal_places) - ) - } else { - int_str - }; - - if is_negative { - format!("-{}", result) - } else { - result - } -} - -/// Format a currency value with locale-appropriate symbol and placement. -/// -/// Examples: -/// - en-US / USD: $1,234.56 -/// - de-DE / EUR: 1.234,56 € -/// - ja / JPY: ¥1,235 -pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String { - let (symbol, prefix, decimals) = currency_info(currency_code); - let formatted = format_number(value, locale, decimals); - - if prefix { - format!("{}{}", symbol, formatted) - } else { - format!("{} {}", formatted, symbol) - } -} - -/// Format an integer with locale-appropriate grouping separators. -pub fn format_integer(value: i64, locale: &Locale) -> String { - let (group_sep, _) = separators_for_locale(locale); - let is_negative = value < 0; - let abs_val = value.unsigned_abs(); - let grouped = format_integer_with_grouping(abs_val, group_sep); - if is_negative { - format!("-{}", grouped) - } else { - grouped - } -} - -/// Format a percentage (0.85 → "85%", locale-aware). -pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String { - let pct = value * 100.0; - let (_, decimal_sep) = separators_for_locale(locale); - let int_part = pct.trunc() as u64; - let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64; - - if decimal_places > 0 { - format!( - "{}{}{}%", - int_part, - decimal_sep, - format!("{:0>width$}", frac, width = decimal_places) - ) - } else { - format!("{}%", int_part) - } -} - -// --- Internal helpers --- - -fn separators_for_locale(locale: &Locale) -> (char, char) { - match locale.language.as_str() { - // Comma grouping, period decimal (en-US style) - "en" | "ja" | "ko" | "zh" | "th" => (',', '.'), - // Period grouping, comma decimal (European style) - "de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','), - // Thin space grouping, comma decimal (French style) - "fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','), - // Default: comma grouping, period decimal - _ => (',', '.'), - } -} - -fn format_integer_with_grouping(value: u64, sep: char) -> String { - let s = value.to_string(); - if s.len() <= 3 { - return s; - } - let mut result = String::new(); - let chars: Vec = s.chars().collect(); - let len = chars.len(); - for (i, &ch) in chars.iter().enumerate() { - if i > 0 && (len - i) % 3 == 0 { - result.push(sep); - } - result.push(ch); - } - result -} - -fn round_to(value: f64, places: usize) -> f64 { - let factor = 10f64.powi(places as i32); - (value * factor).round() / factor -} - -fn currency_info(code: &str) -> (&'static str, bool, usize) { - // (symbol, prefix, decimal_places) - match code.to_uppercase().as_str() { - "USD" => ("$", true, 2), - "EUR" => ("€", false, 2), - "GBP" => ("£", true, 2), - "JPY" => ("¥", true, 0), - "CNY" => ("¥", true, 2), - "KRW" => ("₩", true, 0), - "INR" => ("₹", true, 2), - "CHF" => ("CHF", true, 2), - "CAD" => ("CA$", true, 2), - "AUD" => ("A$", true, 2), - "BRL" => ("R$", true, 2), - "MXN" => ("MX$", true, 2), - "RUB" => ("₽", false, 2), - "SEK" => ("kr", false, 2), - "NOK" => ("kr", false, 2), - "DKK" => ("kr", false, 2), - "PLN" => ("zł", false, 2), - "TRY" => ("₺", true, 2), - "SAR" => ("﷼", false, 2), - "AED" => ("د.إ", false, 2), - _ => ("¤", true, 2), // generic currency sign for unknown codes - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_number_en_us() { - let locale = Locale::en_us(); - assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89"); - } - - #[test] - fn format_number_de() { - let locale = Locale::new("de-DE"); - assert_eq!(format_number(1234.56, &locale, 2), "1.234,56"); - } - - #[test] - fn format_number_no_decimals() { - let locale = Locale::en_us(); - assert_eq!(format_number(42.0, &locale, 0), "42"); - } - - #[test] - fn format_number_negative() { - let locale = Locale::en_us(); - assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00"); - } - - #[test] - fn format_currency_usd() { - let locale = Locale::en_us(); - assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56"); - } - - #[test] - fn format_currency_jpy_no_decimals() { - let locale = Locale::ja(); - assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234"); - } - - #[test] - fn format_integer_groups() { - let locale = Locale::en_us(); - assert_eq!(format_integer(1000000, &locale), "1,000,000"); - } - - #[test] - fn format_integer_small() { - let locale = Locale::en_us(); - assert_eq!(format_integer(42, &locale), "42"); - } - - #[test] - fn format_percent_whole() { - let locale = Locale::en_us(); - assert_eq!(format_percent(0.85, &locale, 0), "85%"); - } - - #[test] - fn format_percent_with_decimal() { - let locale = Locale::en_us(); - assert_eq!(format_percent(0.856, &locale, 1), "85.6%"); - } -} diff --git a/vessels/el-i18n/src/lib.rs b/vessels/el-i18n/src/lib.rs deleted file mode 100644 index 06876d7..0000000 --- a/vessels/el-i18n/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! el-i18n — Localization for el-ui. -//! -//! RTL-aware, plural forms, CLDR-based number/currency formatting. -//! -//! ## Quick start -//! -//! ``` -//! use el_i18n::prelude::*; -//! use std::collections::HashMap; -//! -//! // Build a bundle -//! let mut bundle = LocaleBundle::new(Locale::en_us()); -//! bundle.insert("profile.follow", "Follow"); -//! let mut forms = HashMap::new(); -//! forms.insert("one".to_string(), "{n} Follower".to_string()); -//! forms.insert("other".to_string(), "{n} Followers".to_string()); -//! bundle.insert_plural("profile.followers", forms); -//! -//! // Create a context -//! let ctx = LocaleContext::new(Locale::en_us(), bundle); -//! -//! // Translate -//! assert_eq!(ctx.t("profile.follow"), "Follow"); -//! assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower"); -//! assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers"); -//! ``` - -#![deny(warnings)] - -pub mod bundle; -pub mod format; -pub mod locale; -pub mod plural; -pub mod t; - -pub mod prelude { - pub use crate::bundle::{load_toml, LocaleBundle, TranslationValue}; - pub use crate::format::{format_currency, format_integer, format_number, format_percent}; - pub use crate::locale::{Locale, TextDirection}; - pub use crate::plural::{plural_form, PluralForm}; - pub use crate::t::LocaleContext; -} - -pub use prelude::*; diff --git a/vessels/el-i18n/src/locale.rs b/vessels/el-i18n/src/locale.rs deleted file mode 100644 index 9ba968f..0000000 --- a/vessels/el-i18n/src/locale.rs +++ /dev/null @@ -1,182 +0,0 @@ -/// Locale — language + optional region + directionality. -/// -/// Locale identifies both the language for translation lookup and the -/// region for number/date/currency formatting. - -/// Text direction. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextDirection { - LeftToRight, - RightToLeft, -} - -/// A locale identifier. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Locale { - /// BCP 47 language tag (e.g. "en", "ar", "zh-Hant"). - pub language: String, - /// Optional region (e.g. "US", "GB", "TW"). - pub region: Option, -} - -impl Locale { - /// Create from a BCP 47 tag like "en-US" or "ar". - pub fn new(tag: impl Into) -> Self { - let tag = tag.into(); - if let Some(idx) = tag.find('-') { - let (lang, rest) = tag.split_at(idx); - let region = rest.trim_start_matches('-'); - Self { - language: lang.to_lowercase(), - region: if region.is_empty() { - None - } else { - Some(region.to_uppercase()) - }, - } - } else { - Self { - language: tag.to_lowercase(), - region: None, - } - } - } - - /// The full BCP 47 tag (e.g. "en-US"). - pub fn tag(&self) -> String { - match &self.region { - Some(r) => format!("{}-{}", self.language, r), - None => self.language.clone(), - } - } - - /// The text direction for this locale. - pub fn direction(&self) -> TextDirection { - if self.is_rtl() { - TextDirection::RightToLeft - } else { - TextDirection::LeftToRight - } - } - - /// Whether this locale uses right-to-left script. - pub fn is_rtl(&self) -> bool { - // RTL language codes per Unicode CLDR - matches!( - self.language.as_str(), - "ar" // Arabic - | "he" | "iw" // Hebrew - | "fa" | "per" // Persian/Farsi - | "ur" // Urdu - | "ps" // Pashto - | "ug" // Uyghur - | "yi" // Yiddish - | "dv" // Maldivian/Dhivehi - | "ku" // Kurdish (some scripts) - | "sd" // Sindhi - ) - } - - /// English (US). - pub fn en_us() -> Self { - Self::new("en-US") - } - - /// English (GB). - pub fn en_gb() -> Self { - Self::new("en-GB") - } - - /// Arabic (a common RTL locale). - pub fn ar() -> Self { - Self::new("ar") - } - - /// Arabic (Saudi Arabia). - pub fn ar_sa() -> Self { - Self::new("ar-SA") - } - - /// Spanish (Spain). - pub fn es_es() -> Self { - Self::new("es-ES") - } - - /// French (France). - pub fn fr_fr() -> Self { - Self::new("fr-FR") - } - - /// Japanese. - pub fn ja() -> Self { - Self::new("ja") - } - - /// Chinese (Traditional). - pub fn zh_hant() -> Self { - Self::new("zh-Hant") - } -} - -impl std::fmt::Display for Locale { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.tag()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn locale_parse_with_region() { - let l = Locale::new("en-US"); - assert_eq!(l.language, "en"); - assert_eq!(l.region, Some("US".to_string())); - } - - #[test] - fn locale_parse_without_region() { - let l = Locale::new("ja"); - assert_eq!(l.language, "ja"); - assert_eq!(l.region, None); - } - - #[test] - fn locale_tag_roundtrip() { - let l = Locale::new("fr-FR"); - assert_eq!(l.tag(), "fr-FR"); - } - - #[test] - fn arabic_is_rtl() { - assert!(Locale::new("ar").is_rtl()); - assert!(Locale::new("ar-SA").is_rtl()); - } - - #[test] - fn hebrew_is_rtl() { - assert!(Locale::new("he").is_rtl()); - } - - #[test] - fn persian_is_rtl() { - assert!(Locale::new("fa").is_rtl()); - } - - #[test] - fn english_is_ltr() { - assert!(!Locale::new("en").is_rtl()); - assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight); - } - - #[test] - fn rtl_direction() { - assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft); - } - - #[test] - fn locale_display() { - assert_eq!(format!("{}", Locale::en_us()), "en-US"); - } -} diff --git a/vessels/el-i18n/src/plural.rs b/vessels/el-i18n/src/plural.rs deleted file mode 100644 index b4dc6be..0000000 --- a/vessels/el-i18n/src/plural.rs +++ /dev/null @@ -1,189 +0,0 @@ -/// Plural forms — handles language-specific plurality rules. -/// -/// Different languages have very different plural forms. English has two -/// (one / other). Russian has four. Arabic has six. This module maps counts -/// to the correct form for a given locale. - -/// Named plural categories per Unicode CLDR. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PluralForm { - /// Exactly zero (some languages have a special zero form). - Zero, - /// Exactly one. - One, - /// Small numbers (e.g. 2–4 in Slavic languages). - Two, - /// Few (language-specific). - Few, - /// Many (language-specific). - Many, - /// The catch-all / default. - Other, -} - -/// Determine the plural form for a count in a given language. -/// -/// Rules are a simplified implementation of CLDR plural rules for the most -/// common languages. The full CLDR spec covers hundreds of languages; these -/// cover the major cases. -pub fn plural_form(language: &str, count: i64) -> PluralForm { - let n = count.unsigned_abs(); // absolute value for matching - - match language { - // English and most Western European languages: one / other - "en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi" - | "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id" - | "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => { - if n == 1 { PluralForm::One } else { PluralForm::Other } - } - - // French: one for 0 and 1, other for rest - "fr" => { - if n <= 1 { PluralForm::One } else { PluralForm::Other } - } - - // Russian, Ukrainian, Belarusian: complex Slavic rules - "ru" | "uk" | "be" => { - let n10 = n % 10; - let n100 = n % 100; - if n10 == 1 && n100 != 11 { - PluralForm::One - } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) { - PluralForm::Few - } else { - PluralForm::Many - } - } - - // Polish: similar Slavic rules - "pl" => { - let n10 = n % 10; - let n100 = n % 100; - if n == 1 { - PluralForm::One - } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) { - PluralForm::Few - } else { - PluralForm::Many - } - } - - // Czech, Slovak - "cs" | "sk" => { - if n == 1 { - PluralForm::One - } else if (2..=4).contains(&n) { - PluralForm::Few - } else { - PluralForm::Other - } - } - - // Arabic: 6 forms - "ar" => { - let n100 = n % 100; - if n == 0 { - PluralForm::Zero - } else if n == 1 { - PluralForm::One - } else if n == 2 { - PluralForm::Two - } else if (3..=10).contains(&n100) { - PluralForm::Few - } else if (11..=99).contains(&n100) { - PluralForm::Many - } else { - PluralForm::Other - } - } - - // Hebrew - "he" | "iw" => { - if n == 1 { - PluralForm::One - } else if n == 2 { - PluralForm::Two - } else if n >= 11 && n % 10 == 0 { - PluralForm::Many - } else { - PluralForm::Other - } - } - - // Default: one / other - _ => { - if n == 1 { PluralForm::One } else { PluralForm::Other } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn english_one() { - assert_eq!(plural_form("en", 1), PluralForm::One); - } - - #[test] - fn english_other() { - assert_eq!(plural_form("en", 0), PluralForm::Other); - assert_eq!(plural_form("en", 2), PluralForm::Other); - assert_eq!(plural_form("en", 100), PluralForm::Other); - } - - #[test] - fn french_zero_is_one() { - assert_eq!(plural_form("fr", 0), PluralForm::One); - assert_eq!(plural_form("fr", 1), PluralForm::One); - assert_eq!(plural_form("fr", 2), PluralForm::Other); - } - - #[test] - fn russian_one() { - assert_eq!(plural_form("ru", 1), PluralForm::One); - assert_eq!(plural_form("ru", 21), PluralForm::One); - assert_eq!(plural_form("ru", 101), PluralForm::One); - } - - #[test] - fn russian_few() { - assert_eq!(plural_form("ru", 2), PluralForm::Few); - assert_eq!(plural_form("ru", 3), PluralForm::Few); - assert_eq!(plural_form("ru", 22), PluralForm::Few); - } - - #[test] - fn russian_many() { - assert_eq!(plural_form("ru", 5), PluralForm::Many); - assert_eq!(plural_form("ru", 11), PluralForm::Many); - assert_eq!(plural_form("ru", 20), PluralForm::Many); - } - - #[test] - fn arabic_zero() { - assert_eq!(plural_form("ar", 0), PluralForm::Zero); - } - - #[test] - fn arabic_two() { - assert_eq!(plural_form("ar", 2), PluralForm::Two); - } - - #[test] - fn arabic_few() { - assert_eq!(plural_form("ar", 5), PluralForm::Few); - assert_eq!(plural_form("ar", 10), PluralForm::Few); - } - - #[test] - fn arabic_many() { - assert_eq!(plural_form("ar", 15), PluralForm::Many); - } - - #[test] - fn hebrew_two() { - assert_eq!(plural_form("he", 2), PluralForm::Two); - } -} diff --git a/vessels/el-i18n/src/t.rs b/vessels/el-i18n/src/t.rs deleted file mode 100644 index 281f25a..0000000 --- a/vessels/el-i18n/src/t.rs +++ /dev/null @@ -1,209 +0,0 @@ -/// The t() translation function and LocaleContext. -/// -/// Components call `ctx.t("key")` or `ctx.t_plural("key", count)`. -/// The context flows down from the experience root and carries the active bundle. - -use std::collections::HashMap; -use crate::bundle::LocaleBundle; -use crate::locale::Locale; - -/// The active localization context. -/// -/// Holds the current locale and its translation bundle. Passed down -/// through the component tree. When the locale changes, the context -/// is updated and all components that used it re-render. -#[derive(Debug, Clone)] -pub struct LocaleContext { - pub locale: Locale, - bundle: LocaleBundle, - /// Fallback bundle (typically English) used when a key is missing. - fallback: Option, -} - -impl LocaleContext { - /// Create a context with a locale and its bundle. - pub fn new(locale: Locale, bundle: LocaleBundle) -> Self { - Self { - locale, - bundle, - fallback: None, - } - } - - /// Set a fallback bundle for missing keys. - pub fn with_fallback(mut self, fallback: LocaleBundle) -> Self { - self.fallback = Some(fallback); - self - } - - /// Translate a key with optional variable interpolation. - /// - /// Returns the key itself if not found (never panics). - pub fn t(&self, key: &str) -> String { - self.t_vars(key, &HashMap::new()) - } - - /// Translate a key with variables. - pub fn t_vars(&self, key: &str, vars: &HashMap<&str, String>) -> String { - // Try primary bundle - if let Some(s) = self.bundle.translate(key, vars) { - return s; - } - // Try fallback bundle - if let Some(ref fb) = self.fallback { - if let Some(s) = fb.translate(key, vars) { - return s; - } - } - // Return the key itself — visible but never panics - key.to_string() - } - - /// Translate a plural key with a count. - pub fn t_plural(&self, key: &str, count: i64) -> String { - self.t_plural_vars(key, count, &HashMap::new()) - } - - /// Translate a plural key with a count and extra variables. - pub fn t_plural_vars( - &self, - key: &str, - count: i64, - vars: &HashMap<&str, String>, - ) -> String { - if let Some(s) = self.bundle.translate_plural(key, count, vars) { - return s; - } - if let Some(ref fb) = self.fallback { - if let Some(s) = fb.translate_plural(key, count, vars) { - return s; - } - } - key.to_string() - } - - /// The current locale tag (e.g. "en-US"). - pub fn locale_tag(&self) -> String { - self.locale.tag() - } - - /// Whether the current locale is RTL. - pub fn is_rtl(&self) -> bool { - self.locale.is_rtl() - } -} - -/// Convenience macro for translation calls. -/// -/// ```ignore -/// // Simple -/// let text = t!(ctx, "profile.follow"); -/// -/// // With variables -/// let text = t!(ctx, "greeting", name => "Alice"); -/// -/// // Plural -/// let text = t_n!(ctx, "profile.followers", count); -/// ``` -#[macro_export] -macro_rules! t { - ($ctx:expr, $key:expr) => { - $ctx.t($key) - }; - ($ctx:expr, $key:expr, $($var:ident => $val:expr),+) => {{ - let mut vars = std::collections::HashMap::new(); - $(vars.insert(stringify!($var), $val.to_string());)+ - $ctx.t_vars($key, &vars) - }}; -} - -#[macro_export] -macro_rules! t_n { - ($ctx:expr, $key:expr, $count:expr) => { - $ctx.t_plural($key, $count as i64) - }; - ($ctx:expr, $key:expr, $count:expr, $($var:ident => $val:expr),+) => {{ - let mut vars = std::collections::HashMap::new(); - $(vars.insert(stringify!($var), $val.to_string());)+ - $ctx.t_plural_vars($key, $count as i64, &vars) - }}; -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::bundle::LocaleBundle; - - fn make_ctx() -> LocaleContext { - let mut bundle = LocaleBundle::new(Locale::en_us()); - bundle.insert("profile.follow", "Follow"); - bundle.insert("greeting", "Hello, {name}!"); - let mut forms = HashMap::new(); - forms.insert("one".to_string(), "{n} Follower".to_string()); - forms.insert("other".to_string(), "{n} Followers".to_string()); - bundle.insert_plural("profile.followers", forms); - LocaleContext::new(Locale::en_us(), bundle) - } - - #[test] - fn t_simple() { - let ctx = make_ctx(); - assert_eq!(ctx.t("profile.follow"), "Follow"); - } - - #[test] - fn t_missing_returns_key() { - let ctx = make_ctx(); - assert_eq!(ctx.t("no.such.key"), "no.such.key"); - } - - #[test] - fn t_vars_interpolation() { - let ctx = make_ctx(); - let mut vars = HashMap::new(); - vars.insert("name", "Bob".to_string()); - assert_eq!(ctx.t_vars("greeting", &vars), "Hello, Bob!"); - } - - #[test] - fn t_plural_one() { - let ctx = make_ctx(); - assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower"); - } - - #[test] - fn t_plural_many() { - let ctx = make_ctx(); - assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers"); - } - - #[test] - fn is_rtl_english() { - let ctx = make_ctx(); - assert!(!ctx.is_rtl()); - } - - #[test] - fn is_rtl_arabic() { - let bundle = LocaleBundle::new(Locale::ar()); - let ctx = LocaleContext::new(Locale::ar(), bundle); - assert!(ctx.is_rtl()); - } - - #[test] - fn fallback_bundle_used() { - let primary_bundle = LocaleBundle::new(Locale::new("fr")); - // Key not in French bundle - let mut fallback = LocaleBundle::new(Locale::en_us()); - fallback.insert("app.name", "My App"); - let ctx = LocaleContext::new(Locale::new("fr"), primary_bundle) - .with_fallback(fallback); - assert_eq!(ctx.t("app.name"), "My App"); - } - - #[test] - fn locale_tag() { - let ctx = make_ctx(); - assert_eq!(ctx.locale_tag(), "en-US"); - } -} diff --git a/vessels/el-identity/Cargo.toml b/vessels/el-identity/Cargo.toml deleted file mode 100644 index 61dfc49..0000000 --- a/vessels/el-identity/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "el-identity" -version = "0.1.0" -edition = "2021" -description = "el-ui Engram-native identity — users, roles, scopes, OAuth, and sessions as graph nodes" -license = "MIT" - -[lib] -name = "el_identity" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } -base64 = "0.22" -hmac = "0.12" -sha2 = "0.10" - -[dev-dependencies] diff --git a/vessels/el-identity/src/context.rs b/vessels/el-identity/src/context.rs deleted file mode 100644 index eb4fc9b..0000000 --- a/vessels/el-identity/src/context.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! IdentityContext — the resolved identity of the current caller. -//! -//! Populated by `AuthGuard` during request processing. Contains the User node, -//! their roles (graph-resolved), their scopes (role→scope edges), and the active -//! session node. -//! -//! Everything downstream in the request receives an `IdentityContext` — not raw -//! tokens, not string maps. - -use crate::nodes::{Role, Scope, Session, User}; - -/// The fully-resolved identity context for an authenticated request. -/// -/// Created by `AuthGuard::authenticate()` after: -/// 1. Validating the session node (expiry check) -/// 2. Loading the User node from graph -/// 3. Traversing `User ──has_role──▶ Role` edges -/// 4. Traversing `Role ──grants──▶ Scope` edges -#[derive(Debug, Clone)] -pub struct IdentityContext { - /// The authenticated user. - pub user: User, - /// The active session node that produced this context. - pub session: Session, - /// All roles held by the user (via has_role edges). - pub roles: Vec, - /// All scopes granted across all roles (via grants edges). - pub scopes: Vec, -} - -impl IdentityContext { - pub fn new(user: User, session: Session, roles: Vec, scopes: Vec) -> Self { - Self { user, session, roles, scopes } - } - - /// Check whether the user has a specific role by name. - pub fn has_role(&self, role_name: &str) -> bool { - self.roles.iter().any(|r| r.name == role_name) - } - - /// Check whether the user has a specific scope by name. - pub fn has_scope(&self, scope_name: &str) -> bool { - self.scopes.iter().any(|s| s.name == scope_name) - } - - /// Check whether the user has a specific permission (via any role). - pub fn has_permission(&self, permission: &str) -> bool { - self.roles.iter().any(|r| r.has_permission(permission)) - } - - /// The user's ID as a string (convenience accessor). - pub fn user_id(&self) -> &str { - // UUID's Display impl gives the hyphenated string form - // We lazily format it; callers cache as needed. - // To avoid allocation on every call, store a pre-formatted string. - // For simplicity we use the node's UUID directly via format — this is - // framework infrastructure code called once per request boundary. - let _ = (); - // Returned as a borrowed string from the session (which stores user_id as UUID). - // We work around the lifetime by returning user.id formatted on the fly. - // In a real app this would be &str from a pre-computed field. - self._user_id_buf() - } - - fn _user_id_buf(&self) -> &str { - // This is a limitation of returning &str from a UUID without allocation. - // The idiomatic approach is to expose the Uuid directly. - // We provide user_uuid() as the primary accessor. - "" - } - - /// The user's UUID. - pub fn user_uuid(&self) -> uuid::Uuid { - self.user.id - } - - /// The user's email. - pub fn email(&self) -> &str { - &self.user.email - } - - /// The user's display name. - pub fn display_name(&self) -> &str { - &self.user.display_name - } - - /// All role names as strings (for passing to AOP metadata). - pub fn role_names(&self) -> Vec { - self.roles.iter().map(|r| r.name.clone()).collect() - } - - /// All scope names as strings. - pub fn scope_names(&self) -> Vec { - self.scopes.iter().map(|s| s.name.clone()).collect() - } -} diff --git a/vessels/el-identity/src/engram.rs b/vessels/el-identity/src/engram.rs deleted file mode 100644 index f1274c0..0000000 --- a/vessels/el-identity/src/engram.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! EngramClient trait — thin abstraction over the Engram graph engine. -//! -//! el-identity does not depend on the Engram crate directly. Instead, it -//! defines this trait and accepts any implementor. In production, the host -//! application wires in a real Engram client. In tests, `MockEngramClient` -//! provides an in-memory HashMap-backed implementation. - -use crate::error::IdentityError; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -/// Minimal graph operations required by el-identity. -pub trait EngramClient: Send + Sync { - /// Fetch a node by its ID. Returns `None` if the node does not exist. - fn get_node(&self, id: &str) -> Result, IdentityError>; - - /// Create a new node of the given type with the given data. - /// Returns the new node's ID. - fn create_node( - &self, - node_type: &str, - data: serde_json::Value, - ) -> Result; - - /// Create a directed edge between two nodes. - fn create_edge( - &self, - from: &str, - to: &str, - edge_type: &str, - ) -> Result<(), IdentityError>; - - /// Find nodes of the given type matching the query (field equality). - fn find_nodes( - &self, - node_type: &str, - query: serde_json::Value, - ) -> Result, IdentityError>; - - /// Delete a node by ID. Edges referencing it are also removed. - fn delete_node(&self, id: &str) -> Result<(), IdentityError>; - - /// Find all nodes reachable from `from_id` via `edge_type`. - fn find_connected( - &self, - from_id: &str, - edge_type: &str, - ) -> Result, IdentityError>; -} - -// ── MockEngramClient ────────────────────────────────────────────────────────── - -#[derive(Debug)] -struct NodeEntry { - node_type: String, - data: serde_json::Value, -} - -#[derive(Debug, Clone)] -struct Edge { - from: String, - to: String, - edge_type: String, -} - -/// In-memory Engram client for unit tests. -/// -/// Stores nodes in a `HashMap` and edges in a `Vec`. -/// Thread-safe via `RwLock`. -#[derive(Debug, Default)] -pub struct MockEngramClient { - nodes: RwLock>, - edges: RwLock>, -} - -impl MockEngramClient { - pub fn new() -> Self { - Self::default() - } - - /// Count nodes of a specific type (useful in tests). - pub fn count_nodes(&self, node_type: &str) -> usize { - self.nodes - .read() - .expect("nodes lock poisoned") - .values() - .filter(|n| n.node_type == node_type) - .count() - } - - /// Count edges of a specific type. - pub fn count_edges(&self, edge_type: &str) -> usize { - self.edges - .read() - .expect("edges lock poisoned") - .iter() - .filter(|e| e.edge_type == edge_type) - .count() - } -} - -impl EngramClient for MockEngramClient { - fn get_node(&self, id: &str) -> Result, IdentityError> { - let nodes = self.nodes.read().expect("nodes lock poisoned"); - Ok(nodes.get(id).map(|n| n.data.clone())) - } - - fn create_node( - &self, - node_type: &str, - data: serde_json::Value, - ) -> Result { - // Extract the node's own "id" field if present, otherwise generate one. - let id = data - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - - self.nodes - .write() - .expect("nodes lock poisoned") - .insert( - id.clone(), - NodeEntry { - node_type: node_type.to_string(), - data, - }, - ); - Ok(id) - } - - fn create_edge( - &self, - from: &str, - to: &str, - edge_type: &str, - ) -> Result<(), IdentityError> { - self.edges.write().expect("edges lock poisoned").push(Edge { - from: from.to_string(), - to: to.to_string(), - edge_type: edge_type.to_string(), - }); - Ok(()) - } - - fn find_nodes( - &self, - node_type: &str, - query: serde_json::Value, - ) -> Result, IdentityError> { - let nodes = self.nodes.read().expect("nodes lock poisoned"); - let results = nodes - .values() - .filter(|n| n.node_type == node_type) - .filter(|n| matches_query(&n.data, &query)) - .map(|n| n.data.clone()) - .collect(); - Ok(results) - } - - fn delete_node(&self, id: &str) -> Result<(), IdentityError> { - self.nodes - .write() - .expect("nodes lock poisoned") - .remove(id); - // Remove any edges referencing this node. - self.edges - .write() - .expect("edges lock poisoned") - .retain(|e| e.from != id && e.to != id); - Ok(()) - } - - fn find_connected( - &self, - from_id: &str, - edge_type: &str, - ) -> Result, IdentityError> { - let edges = self.edges.read().expect("edges lock poisoned"); - let to_ids: Vec = edges - .iter() - .filter(|e| e.from == from_id && e.edge_type == edge_type) - .map(|e| e.to.clone()) - .collect(); - drop(edges); - - let nodes = self.nodes.read().expect("nodes lock poisoned"); - let results = to_ids - .iter() - .filter_map(|id| nodes.get(id).map(|n| n.data.clone())) - .collect(); - Ok(results) - } -} - -/// Check whether a node's data matches all key-value pairs in the query. -fn matches_query(data: &serde_json::Value, query: &serde_json::Value) -> bool { - if let serde_json::Value::Object(q_map) = query { - if q_map.is_empty() { - return true; - } - if let serde_json::Value::Object(data_map) = data { - return q_map.iter().all(|(k, v)| data_map.get(k) == Some(v)); - } - return false; - } - true // empty or non-object query matches everything -} - -/// Convenience: wrap a MockEngramClient in Arc for trait object use. -pub fn mock_client() -> Arc { - Arc::new(MockEngramClient::new()) -} diff --git a/vessels/el-identity/src/error.rs b/vessels/el-identity/src/error.rs deleted file mode 100644 index b6b199d..0000000 --- a/vessels/el-identity/src/error.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! IdentityError — all errors from the el-identity system. - -use thiserror::Error; - -#[derive(Debug, Error, Clone)] -pub enum IdentityError { - #[error("user not found: {0}")] - UserNotFound(String), - - #[error("session not found or expired")] - SessionNotFound, - - #[error("session expired")] - SessionExpired, - - #[error("OAuth error: {0}")] - OAuthError(String), - - #[error("OAuth provider not configured: {0}")] - ProviderNotConfigured(String), - - #[error("token exchange failed: {status} {body}")] - TokenExchangeFailed { status: u16, body: String }, - - #[error("token refresh failed: {0}")] - TokenRefreshFailed(String), - - #[error("PKCE verification failed")] - PkceVerificationFailed, - - #[error("graph error: {0}")] - GraphError(String), - - #[error("serialization error: {0}")] - SerializationError(String), - - #[error("authentication required")] - Unauthenticated, - - #[error("forbidden: requires role '{0}'")] - Forbidden(String), - - #[error("forbidden: requires scope '{0}'")] - ScopeForbidden(String), - - #[error("invalid credentials")] - InvalidCredentials, - - #[error("role not found: {0}")] - RoleNotFound(String), - - #[error("node not found: {0}")] - NodeNotFound(String), -} - -pub type IdentityResult = Result; diff --git a/vessels/el-identity/src/guard.rs b/vessels/el-identity/src/guard.rs deleted file mode 100644 index 9fb3dd0..0000000 --- a/vessels/el-identity/src/guard.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! AuthGuard — the mechanism behind `@authenticate`. -//! -//! `AuthGuard` is the bridge between a raw session/JWT token string (extracted -//! from the request) and a fully-resolved `IdentityContext`. -//! -//! Execution: -//! 1. Extract session ID from the token (JWT decode or opaque lookup) -//! 2. Validate the Session node in Engram (expiry check) -//! 3. Load the User node -//! 4. Traverse `User ──has_role──▶ Role` edges -//! 5. Traverse `Role ──grants──▶ Scope` edges -//! 6. Return `IdentityContext` — fully resolved, ready for downstream use -//! -//! `@public` bypasses this guard entirely (see `el-aop::PublicMarker`). - -use crate::{ - context::IdentityContext, - engram::EngramClient, - error::{IdentityError, IdentityResult}, - nodes::{Role, Scope, User, EDGE_GRANTS, EDGE_HAS_ROLE, NODE_ROLE, NODE_SCOPE, NODE_USER}, - session::SessionManager, -}; -use std::sync::Arc; - -/// AuthGuard resolves a session/token string into a full `IdentityContext`. -/// -/// Configured once at application startup and shared across requests. -pub struct AuthGuard { - client: Arc, - session_manager: Arc, -} - -impl AuthGuard { - pub fn new(client: Arc, session_manager: Arc) -> Self { - Self { client, session_manager } - } - - /// Authenticate a request given its session ID. - /// - /// This is called by `@authenticate` in the AOP chain. For every protected - /// endpoint, this runs before the handler. If it returns `Err`, the request - /// is rejected. - pub fn authenticate(&self, session_id: &str) -> IdentityResult { - // 1. Validate session (checks expiry, lazy-deletes expired) - let session = self.session_manager.validate(session_id)?; - - // 2. Load User node - let user_id_str = session.user_id.to_string(); - let user_node = self - .client - .get_node(&user_id_str) - .map_err(|e| IdentityError::GraphError(e.to_string()))? - .ok_or_else(|| IdentityError::UserNotFound(user_id_str.clone()))?; - - let user = User::from_value(&user_node) - .ok_or_else(|| IdentityError::GraphError("user node parse failed".into()))?; - - // 3. Load roles via has_role edges - let role_nodes = self - .client - .find_connected(&user_id_str, EDGE_HAS_ROLE) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - let roles: Vec = role_nodes - .iter() - .filter_map(Role::from_value) - .collect(); - - // 4. Load scopes via grants edges from each role - let mut scopes: Vec = Vec::new(); - for role in &roles { - let scope_nodes = self - .client - .find_connected(&role.id.to_string(), EDGE_GRANTS) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - scopes.extend(scope_nodes.iter().filter_map(Scope::from_value)); - } - - // Deduplicate scopes by name - scopes.dedup_by(|a, b| a.name == b.name); - - Ok(IdentityContext::new(user, session, roles, scopes)) - } - - /// Require a specific role — returns Err::Forbidden if missing. - pub fn require_role( - &self, - ctx: &IdentityContext, - role: &str, - ) -> IdentityResult<()> { - if ctx.has_role(role) { - Ok(()) - } else { - Err(IdentityError::Forbidden(role.to_string())) - } - } - - /// Require a specific scope — returns Err::ScopeForbidden if missing. - pub fn require_scope( - &self, - ctx: &IdentityContext, - scope: &str, - ) -> IdentityResult<()> { - if ctx.has_scope(scope) { - Ok(()) - } else { - Err(IdentityError::ScopeForbidden(scope.to_string())) - } - } - - /// Register a user in the Engram graph. - /// - /// Creates the User node. Call this after first OAuth login or on signup. - pub fn register_user(&self, user: &User) -> IdentityResult<()> { - self.client - .create_node(NODE_USER, user.to_value()) - .map(|_| ()) - .map_err(|e| IdentityError::GraphError(e.to_string())) - } - - /// Assign a role to a user by creating a `has_role` edge. - pub fn assign_role(&self, user_id: &str, role: &Role) -> IdentityResult<()> { - // Ensure role node exists - if self.client.get_node(&role.id.to_string()) - .map_err(|e| IdentityError::GraphError(e.to_string()))? - .is_none() - { - self.client - .create_node(NODE_ROLE, role.to_value()) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - } - // Edge: User → Role - self.client - .create_edge(user_id, &role.id.to_string(), EDGE_HAS_ROLE) - .map_err(|e| IdentityError::GraphError(e.to_string())) - } - - /// Register a scope and link it to a role via a `grants` edge. - pub fn assign_scope_to_role(&self, role: &Role, scope: &Scope) -> IdentityResult<()> { - if self.client.get_node(&scope.id.to_string()) - .map_err(|e| IdentityError::GraphError(e.to_string()))? - .is_none() - { - self.client - .create_node(NODE_SCOPE, scope.to_value()) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - } - self.client - .create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS) - .map_err(|e| IdentityError::GraphError(e.to_string())) - } -} diff --git a/vessels/el-identity/src/lib.rs b/vessels/el-identity/src/lib.rs deleted file mode 100644 index b007fac..0000000 --- a/vessels/el-identity/src/lib.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! el-identity — Engram-native identity for el-ui. -//! -//! Identity is not a bolt-on — it is activation spreading through the graph. -//! Users, roles, scopes, sessions, and OAuth tokens are first-class Engram nodes -//! connected by typed edges. -//! -//! ```text -//! User ──has_role──▶ Role ──grants──▶ Scope -//! │ -//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken -//! ``` -//! -//! Security-by-default: `@authenticate` is applied to every endpoint. -//! `@public` is the explicit opt-out. - -#![deny(warnings)] - -pub mod context; -pub mod engram; -pub mod error; -pub mod guard; -pub mod nodes; -pub mod oauth; -pub mod provider; -pub mod session; - -pub use context::IdentityContext; -pub use engram::{EngramClient, MockEngramClient}; -pub use error::{IdentityError, IdentityResult}; -pub use guard::AuthGuard; -pub use nodes::{OAuthToken, Role, Scope, Session, User}; -pub use oauth::{OAuthFlow, PkceChallenge}; -pub use provider::{AppleOAuth, GithubOAuth, GoogleOAuth, OAuthProvider}; -pub use session::SessionManager; - -#[cfg(test)] -mod tests; diff --git a/vessels/el-identity/src/nodes.rs b/vessels/el-identity/src/nodes.rs deleted file mode 100644 index de576a5..0000000 --- a/vessels/el-identity/src/nodes.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Identity graph nodes — User, Role, Scope, OAuthToken, Session as -//! strongly-typed Engram node structs. -//! -//! Each node maps to an Engram graph node. The identity graph looks like: -//! -//! ```text -//! User ──has_role──▶ Role ──grants──▶ Scope -//! │ -//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken -//! ``` - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ── User ────────────────────────────────────────────────────────────────────── - -/// A user identity node in the Engram graph. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct User { - /// Stable UUID for this user. - pub id: Uuid, - /// Email address — unique identifier for the user. - pub email: String, - /// Display name (may differ from email). - pub display_name: String, - /// When this user node was created. - pub created_at: DateTime, -} - -impl User { - /// Create a new user with a fresh UUID. - pub fn new(email: impl Into, display_name: impl Into) -> Self { - Self { - id: Uuid::new_v4(), - email: email.into(), - display_name: display_name.into(), - created_at: Utc::now(), - } - } - - /// Deserialize from a serde_json::Value (as stored in Engram). - pub fn from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()).ok() - } - - /// Serialize to serde_json::Value for storage in Engram. - pub fn to_value(&self) -> serde_json::Value { - serde_json::to_value(self).expect("User is always serializable") - } -} - -// ── Role ────────────────────────────────────────────────────────────────────── - -/// A role node — grants a set of named permissions to connected User nodes. -/// -/// Edge: `User ──has_role──▶ Role` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Role { - pub id: Uuid, - pub name: String, - /// Flat list of permission strings (e.g., `"orders:read"`, `"users:write"`). - pub permissions: Vec, -} - -impl Role { - pub fn new(name: impl Into) -> Self { - Self { - id: Uuid::new_v4(), - name: name.into(), - permissions: Vec::new(), - } - } - - pub fn with_permission(mut self, perm: impl Into) -> Self { - self.permissions.push(perm.into()); - self - } - - pub fn with_permissions(mut self, perms: impl IntoIterator>) -> Self { - self.permissions.extend(perms.into_iter().map(|p| p.into())); - self - } - - pub fn has_permission(&self, perm: &str) -> bool { - self.permissions.iter().any(|p| p == perm) - } - - pub fn from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()).ok() - } - - pub fn to_value(&self) -> serde_json::Value { - serde_json::to_value(self).expect("Role is always serializable") - } -} - -// ── Scope ───────────────────────────────────────────────────────────────────── - -/// An OAuth scope node. -/// -/// Edge: `Role ──grants──▶ Scope` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Scope { - pub id: Uuid, - /// The OAuth scope string (e.g., `"openid"`, `"email"`, `"profile"`). - pub name: String, - pub description: String, -} - -impl Scope { - pub fn new(name: impl Into, description: impl Into) -> Self { - Self { - id: Uuid::new_v4(), - name: name.into(), - description: description.into(), - } - } - - pub fn from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()).ok() - } - - pub fn to_value(&self) -> serde_json::Value { - serde_json::to_value(self).expect("Scope is always serializable") - } -} - -// ── OAuthToken ──────────────────────────────────────────────────────────────── - -/// An OAuth token node — stores hashed tokens so the graph is breach-safe. -/// -/// Edge: `Session ──authenticated_via──▶ OAuthToken` -/// -/// Tokens are hashed with SHA-256 before storage. The raw token is never -/// persisted — only the hash. Refresh tokens use the same scheme. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct OAuthToken { - pub id: Uuid, - pub provider: String, - /// SHA-256 hex hash of the access token. - pub access_token_hash: String, - /// SHA-256 hex hash of the refresh token, if present. - pub refresh_token_hash: Option, - pub expires_at: DateTime, - /// Scopes granted by this token. - pub scopes: Vec, -} - -impl OAuthToken { - pub fn new( - provider: impl Into, - access_token_hash: impl Into, - refresh_token_hash: Option, - expires_at: DateTime, - scopes: Vec, - ) -> Self { - Self { - id: Uuid::new_v4(), - provider: provider.into(), - access_token_hash: access_token_hash.into(), - refresh_token_hash, - expires_at, - scopes, - } - } - - pub fn is_expired(&self) -> bool { - Utc::now() >= self.expires_at - } - - pub fn has_scope(&self, scope: &str) -> bool { - self.scopes.iter().any(|s| s == scope) - } - - pub fn from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()).ok() - } - - pub fn to_value(&self) -> serde_json::Value { - serde_json::to_value(self).expect("OAuthToken is always serializable") - } -} - -// ── Session ─────────────────────────────────────────────────────────────────── - -/// A session node — represents an active authenticated session. -/// -/// Edge: `User ──has_session──▶ Session` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Session { - pub id: Uuid, - /// The user this session belongs to. - pub user_id: Uuid, - pub created_at: DateTime, - pub expires_at: DateTime, - /// The IP address that created this session. - pub ip_address: Option, -} - -impl Session { - pub fn new(user_id: Uuid, ttl_seconds: i64, ip_address: Option) -> Self { - let now = Utc::now(); - let expires_at = now + chrono::Duration::seconds(ttl_seconds); - Self { - id: Uuid::new_v4(), - user_id, - created_at: now, - expires_at, - ip_address, - } - } - - pub fn is_expired(&self) -> bool { - Utc::now() >= self.expires_at - } - - pub fn from_value(value: &serde_json::Value) -> Option { - serde_json::from_value(value.clone()).ok() - } - - pub fn to_value(&self) -> serde_json::Value { - serde_json::to_value(self).expect("Session is always serializable") - } -} - -// ── Edge type constants ─────────────────────────────────────────────────────── - -/// Edge type: User → Role -pub const EDGE_HAS_ROLE: &str = "has_role"; -/// Edge type: User → Session -pub const EDGE_HAS_SESSION: &str = "has_session"; -/// Edge type: Session → OAuthToken -pub const EDGE_AUTHENTICATED_VIA: &str = "authenticated_via"; -/// Edge type: Role → Scope -pub const EDGE_GRANTS: &str = "grants"; - -// ── Node type constants ─────────────────────────────────────────────────────── - -pub const NODE_USER: &str = "User"; -pub const NODE_ROLE: &str = "Role"; -pub const NODE_SCOPE: &str = "Scope"; -pub const NODE_OAUTH_TOKEN: &str = "OAuthToken"; -pub const NODE_SESSION: &str = "Session"; diff --git a/vessels/el-identity/src/oauth.rs b/vessels/el-identity/src/oauth.rs deleted file mode 100644 index cf28010..0000000 --- a/vessels/el-identity/src/oauth.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! OAuth 2.0 flows implemented as Engram graph operations. -//! -//! Implements PKCE (RFC 7636) and Authorization Code flow without external -//! OAuth crates. Token exchange uses `reqwest` (already in the workspace). -//! All tokens are hashed before graph storage — the raw token never persists. -//! -//! Flow: -//! 1. `begin_auth_flow()` → PKCE verifier + challenge, redirect URL -//! 2. Provider redirects back with `code` -//! 3. `exchange_code()` → calls provider token endpoint, stores OAuthToken node -//! 4. `refresh_token()` → find OAuthToken node, call refresh endpoint, update node - -use crate::{ - engram::EngramClient, - error::{IdentityError, IdentityResult}, - nodes::{OAuthToken, User, EDGE_AUTHENTICATED_VIA, NODE_OAUTH_TOKEN}, - provider::OAuthProvider, - session::SessionManager, -}; -use chrono::{Duration, Utc}; -use sha2::{Digest, Sha256}; -use std::sync::Arc; - -// ── PKCE helpers ────────────────────────────────────────────────────────────── - -/// A PKCE verifier/challenge pair. -#[derive(Debug, Clone)] -pub struct PkceChallenge { - /// The raw verifier — sent to the token endpoint. - pub verifier: String, - /// The challenge (BASE64URL(SHA256(verifier))) — sent in the auth request. - pub challenge: String, - /// Always "S256". - pub method: &'static str, -} - -impl PkceChallenge { - /// Generate a new PKCE verifier and compute the S256 challenge. - /// - /// The verifier is a 43-character URL-safe random string derived from - /// entropy collected from the system clock and a UUID. - pub fn generate() -> Self { - let verifier = generate_pkce_verifier(); - let challenge = pkce_s256_challenge(&verifier); - Self { - verifier, - challenge, - method: "S256", - } - } - - /// Verify that a verifier matches this challenge (used in tests and server-side). - pub fn verify(&self, verifier: &str) -> bool { - pkce_s256_challenge(verifier) == self.challenge - } -} - -fn generate_pkce_verifier() -> String { - // 32 random bytes → base64url (43 chars, no padding) - // We derive entropy from UUID (random in v4) + timestamp nanos. - let id = uuid::Uuid::new_v4(); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - let mut raw = [0u8; 32]; - let id_bytes = id.as_bytes(); - for i in 0..16 { - raw[i] = id_bytes[i]; - } - let ts_bytes = ts.to_le_bytes(); - for i in 0..4 { - raw[16 + i] = ts_bytes[i]; - } - // Fill remaining with XOR mix - for i in 20..32 { - raw[i] = id_bytes[i - 16] ^ ts_bytes[i % 4]; - } - base64url_encode_no_pad(&raw) -} - -fn pkce_s256_challenge(verifier: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - base64url_encode_no_pad(&hasher.finalize()) -} - -fn base64url_encode_no_pad(input: &[u8]) -> String { - const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut out = String::new(); - for chunk in input.chunks(3) { - let b0 = chunk[0] as u32; - let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; - let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; - let n = (b0 << 16) | (b1 << 8) | b2; - out.push(CHARS[((n >> 18) & 63) as usize] as char); - out.push(CHARS[((n >> 12) & 63) as usize] as char); - if chunk.len() > 1 { - out.push(CHARS[((n >> 6) & 63) as usize] as char); - } - if chunk.len() > 2 { - out.push(CHARS[(n & 63) as usize] as char); - } - } - out -} - -/// Hash a token with SHA-256 for safe graph storage. -pub fn hash_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode_lower_sha256(hasher.finalize().as_ref()) -} - -mod hex { - pub fn encode_lower_sha256(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() - } -} - -// ── Authorization Code Flow ─────────────────────────────────────────────────── - -/// Parameters for starting an OAuth authorization code flow. -#[derive(Debug, Clone)] -pub struct AuthFlowParams { - /// The PKCE challenge (save the verifier for use at exchange time). - pub pkce: PkceChallenge, - /// The full redirect URL to send the user to. - pub redirect_url: String, - /// An opaque state value for CSRF protection. - pub state: String, -} - -/// The result of a successful token exchange. -#[derive(Debug, Clone)] -pub struct TokenSet { - pub access_token: String, - pub refresh_token: Option, - /// Expiry in seconds from now. - pub expires_in: u64, - pub scopes: Vec, -} - -/// OAuth flow coordinator — executes auth code + PKCE flows and writes -/// the resulting tokens to the Engram graph. -pub struct OAuthFlow { - client: Arc, - /// Kept for future use (e.g., session creation during OAuth callback). - _session_manager: Arc, -} - -impl OAuthFlow { - pub fn new(client: Arc, session_manager: Arc) -> Self { - Self { client, _session_manager: session_manager } - } - - /// Step 1: Generate the redirect URL and PKCE parameters. - /// - /// The caller should: - /// 1. Save `params.pkce.verifier` in the user's browser session (cookie/localStorage). - /// 2. Redirect the user to `params.redirect_url`. - pub fn begin_auth_flow( - &self, - provider: &dyn OAuthProvider, - redirect_uri: &str, - extra_scopes: &[&str], - ) -> IdentityResult { - let pkce = PkceChallenge::generate(); - let state = generate_pkce_verifier(); // reuse the verifier generator for state - let url = provider.authorization_url( - redirect_uri, - &pkce.challenge, - &state, - extra_scopes, - ); - Ok(AuthFlowParams { pkce, redirect_url: url, state }) - } - - /// Step 2: Exchange the authorization code for tokens. - /// - /// - `user` — the authenticated user to link the token to - /// - `code` — the authorization code from the provider callback - /// - `pkce_verifier` — the verifier saved in step 1 - /// - `session_id` — the session to attach the OAuthToken to - /// - /// Returns the session ID that now has an OAuthToken attached via graph edge. - pub fn exchange_code( - &self, - provider: &dyn OAuthProvider, - _user: &User, - code: &str, - pkce_verifier: &str, - redirect_uri: &str, - session_id: &str, - ) -> IdentityResult { - // Exchange code with provider (HTTP call inside OAuthProvider::exchange_code) - let token_set = provider.exchange_code(code, pkce_verifier, redirect_uri)?; - - // Hash tokens before storing - let access_hash = hash_token(&token_set.access_token); - let refresh_hash = token_set.refresh_token.as_deref().map(hash_token); - let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64); - - let oauth_token = OAuthToken::new( - provider.name(), - access_hash, - refresh_hash, - expires_at, - token_set.scopes, - ); - - // Store OAuthToken node in graph - let token_id = self - .client - .create_node(NODE_OAUTH_TOKEN, oauth_token.to_value()) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - // Edge: Session → OAuthToken (authenticated_via) - self.client - .create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - Ok(oauth_token) - } - - /// Step 3: Refresh an expired access token. - /// - /// Finds the OAuthToken node for the given session, calls the provider's - /// refresh endpoint, and updates the node in-place (delete old, create new). - pub fn refresh_token( - &self, - provider: &dyn OAuthProvider, - session_id: &str, - refresh_token: &str, - ) -> IdentityResult { - // Exchange refresh token with provider - let token_set = provider.refresh_token(refresh_token)?; - - // Find and delete old OAuthToken nodes attached to this session - let old_tokens = self - .client - .find_connected(session_id, EDGE_AUTHENTICATED_VIA) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - for old in &old_tokens { - if let Some(id) = old.get("id").and_then(|v| v.as_str()) { - let _ = self.client.delete_node(id); - } - } - - // Store new token - let access_hash = hash_token(&token_set.access_token); - let refresh_hash = token_set.refresh_token.as_deref().map(hash_token); - let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64); - - let new_token = OAuthToken::new( - provider.name(), - access_hash, - refresh_hash, - expires_at, - token_set.scopes, - ); - - let token_id = self - .client - .create_node(NODE_OAUTH_TOKEN, new_token.to_value()) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - self.client - .create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - Ok(new_token) - } -} diff --git a/vessels/el-identity/src/provider.rs b/vessels/el-identity/src/provider.rs deleted file mode 100644 index 7acafa7..0000000 --- a/vessels/el-identity/src/provider.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! OAuthProvider trait and built-in provider implementations. -//! -//! Each provider knows its own OAuth endpoints and default scopes. -//! Token exchange is done over HTTP using `reqwest` in async or -//! in the sync shim below. For simplicity in a framework context -//! we use blocking HTTP (same pattern as the rest of el-ui). -//! -//! Implementations: `GoogleOAuth`, `AppleOAuth`, `GithubOAuth`. - -use crate::{error::{IdentityError, IdentityResult}, oauth::TokenSet}; -use std::collections::HashMap; - -// ── OAuthProvider trait ─────────────────────────────────────────────────────── - -/// Implemented by each OAuth provider. -pub trait OAuthProvider: Send + Sync { - /// The provider identifier (e.g., `"google"`, `"github"`, `"apple"`). - fn name(&self) -> &'static str; - - /// Build the authorization URL the user is redirected to. - fn authorization_url( - &self, - redirect_uri: &str, - pkce_challenge: &str, - state: &str, - extra_scopes: &[&str], - ) -> String; - - /// Exchange an authorization code for tokens (HTTP POST to token endpoint). - fn exchange_code( - &self, - code: &str, - pkce_verifier: &str, - redirect_uri: &str, - ) -> IdentityResult; - - /// Refresh an access token using a refresh token. - fn refresh_token(&self, refresh_token: &str) -> IdentityResult; - - /// Default scopes requested by this provider. - fn default_scopes(&self) -> Vec<&'static str>; -} - -// ── Shared HTTP helper ──────────────────────────────────────────────────────── - -/// Perform a URL-encoded POST and parse the JSON response. -/// -/// In production this would use an async client. Here we use the blocking -/// reqwest API to keep el-identity sync-friendly (same pattern as el-auth JWT). -/// The actual HTTP call is behind a feature-flag stub so tests never need a -/// running server. -fn post_token_request( - endpoint: &str, - params: &HashMap<&str, &str>, -) -> IdentityResult { - // Attempt real HTTP — fall through to error if reqwest isn't available at - // compile time. Since we don't add reqwest as a dep (no_std compat), we - // return a clear error. The caller (OAuthFlow) is the integration point. - let _ = (endpoint, params); - Err(IdentityError::OAuthError( - "HTTP client not configured: wire in a reqwest::blocking::Client or use the async variant".into(), - )) -} - -/// Parse token endpoint JSON response → TokenSet. -fn parse_token_response(json: &serde_json::Value) -> IdentityResult { - let access_token = json - .get("access_token") - .and_then(|v| v.as_str()) - .ok_or_else(|| IdentityError::OAuthError("missing access_token in response".into()))? - .to_string(); - - let refresh_token = json - .get("refresh_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let expires_in = json - .get("expires_in") - .and_then(|v| v.as_u64()) - .unwrap_or(3600); - - let scope_str = json - .get("scope") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let scopes = scope_str - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - - Ok(TokenSet { - access_token, - refresh_token, - expires_in, - scopes, - }) -} - -// ── GoogleOAuth ─────────────────────────────────────────────────────────────── - -/// Google OAuth 2.0 provider. -/// -/// Endpoints: -/// - Auth: `https://accounts.google.com/o/oauth2/v2/auth` -/// - Token: `https://oauth2.googleapis.com/token` -#[derive(Debug, Clone)] -pub struct GoogleOAuth { - pub client_id: String, - pub client_secret: String, -} - -impl GoogleOAuth { - pub fn new(client_id: impl Into, client_secret: impl Into) -> Self { - Self { - client_id: client_id.into(), - client_secret: client_secret.into(), - } - } - - const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/v2/auth"; - const TOKEN_URL: &'static str = "https://oauth2.googleapis.com/token"; -} - -impl OAuthProvider for GoogleOAuth { - fn name(&self) -> &'static str { - "google" - } - - fn authorization_url( - &self, - redirect_uri: &str, - pkce_challenge: &str, - state: &str, - extra_scopes: &[&str], - ) -> String { - let mut scopes = self.default_scopes(); - scopes.extend_from_slice(extra_scopes); - let scope_str = scopes.join(" "); - format!( - "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline", - Self::AUTH_URL, - url_encode(&self.client_id), - url_encode(redirect_uri), - url_encode(&scope_str), - url_encode(state), - url_encode(pkce_challenge), - ) - } - - fn exchange_code( - &self, - code: &str, - pkce_verifier: &str, - redirect_uri: &str, - ) -> IdentityResult { - let mut params = HashMap::new(); - params.insert("grant_type", "authorization_code"); - params.insert("client_id", &self.client_id); - params.insert("client_secret", &self.client_secret); - params.insert("code", code); - params.insert("code_verifier", pkce_verifier); - params.insert("redirect_uri", redirect_uri); - let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; - parse_token_response(&resp) - } - - fn refresh_token(&self, refresh_token: &str) -> IdentityResult { - let mut params = HashMap::new(); - params.insert("grant_type", "refresh_token"); - params.insert("client_id", &self.client_id); - params.insert("client_secret", &self.client_secret); - params.insert("refresh_token", refresh_token); - let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; - parse_token_response(&resp) - } - - fn default_scopes(&self) -> Vec<&'static str> { - vec!["openid", "email", "profile"] - } -} - -// ── AppleOAuth ──────────────────────────────────────────────────────────────── - -/// Apple Sign In OAuth provider. -/// -/// Endpoints: -/// - Auth: `https://appleid.apple.com/auth/authorize` -/// - Token: `https://appleid.apple.com/auth/token` -/// -/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key. -/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file. -#[derive(Debug, Clone)] -pub struct AppleOAuth { - pub client_id: String, - /// The JWT client secret (pre-generated, rotated manually or via automation). - pub client_secret_jwt: String, - pub team_id: String, -} - -impl AppleOAuth { - pub fn new( - client_id: impl Into, - client_secret_jwt: impl Into, - team_id: impl Into, - ) -> Self { - Self { - client_id: client_id.into(), - client_secret_jwt: client_secret_jwt.into(), - team_id: team_id.into(), - } - } - - const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize"; - const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token"; -} - -impl OAuthProvider for AppleOAuth { - fn name(&self) -> &'static str { - "apple" - } - - fn authorization_url( - &self, - redirect_uri: &str, - pkce_challenge: &str, - state: &str, - extra_scopes: &[&str], - ) -> String { - let mut scopes = self.default_scopes(); - scopes.extend_from_slice(extra_scopes); - let scope_str = scopes.join(" "); - format!( - "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post", - Self::AUTH_URL, - url_encode(&self.client_id), - url_encode(redirect_uri), - url_encode(&scope_str), - url_encode(state), - url_encode(pkce_challenge), - ) - } - - fn exchange_code( - &self, - code: &str, - pkce_verifier: &str, - redirect_uri: &str, - ) -> IdentityResult { - let mut params = HashMap::new(); - params.insert("grant_type", "authorization_code"); - params.insert("client_id", &self.client_id); - params.insert("client_secret", &self.client_secret_jwt); - params.insert("code", code); - params.insert("code_verifier", pkce_verifier); - params.insert("redirect_uri", redirect_uri); - let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; - parse_token_response(&resp) - } - - fn refresh_token(&self, refresh_token: &str) -> IdentityResult { - let mut params = HashMap::new(); - params.insert("grant_type", "refresh_token"); - params.insert("client_id", &self.client_id); - params.insert("client_secret", &self.client_secret_jwt); - params.insert("refresh_token", refresh_token); - let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; - parse_token_response(&resp) - } - - fn default_scopes(&self) -> Vec<&'static str> { - vec!["openid", "email", "name"] - } -} - -// ── GithubOAuth ─────────────────────────────────────────────────────────────── - -/// GitHub OAuth 2.0 provider. -/// -/// GitHub uses a non-standard token endpoint that returns form-encoded data -/// unless `Accept: application/json` is set. -/// -/// Endpoints: -/// - Auth: `https://github.com/login/oauth/authorize` -/// - Token: `https://github.com/login/oauth/access_token` -/// -/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored. -#[derive(Debug, Clone)] -pub struct GithubOAuth { - pub client_id: String, - pub client_secret: String, -} - -impl GithubOAuth { - pub fn new(client_id: impl Into, client_secret: impl Into) -> Self { - Self { - client_id: client_id.into(), - client_secret: client_secret.into(), - } - } - - const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize"; - const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token"; -} - -impl OAuthProvider for GithubOAuth { - fn name(&self) -> &'static str { - "github" - } - - fn authorization_url( - &self, - redirect_uri: &str, - _pkce_challenge: &str, // GitHub does not support PKCE - state: &str, - extra_scopes: &[&str], - ) -> String { - let mut scopes = self.default_scopes(); - scopes.extend_from_slice(extra_scopes); - let scope_str = scopes.join(" "); - format!( - "{}?client_id={}&redirect_uri={}&scope={}&state={}", - Self::AUTH_URL, - url_encode(&self.client_id), - url_encode(redirect_uri), - url_encode(&scope_str), - url_encode(state), - ) - } - - fn exchange_code( - &self, - code: &str, - _pkce_verifier: &str, // GitHub does not support PKCE - redirect_uri: &str, - ) -> IdentityResult { - let mut params = HashMap::new(); - params.insert("client_id", self.client_id.as_str()); - params.insert("client_secret", self.client_secret.as_str()); - params.insert("code", code); - params.insert("redirect_uri", redirect_uri); - let resp = post_token_request(Self::TOKEN_URL, ¶ms)?; - parse_token_response(&resp) - } - - fn refresh_token(&self, _refresh_token: &str) -> IdentityResult { - // GitHub access tokens don't expire by default and don't have refresh tokens. - Err(IdentityError::TokenRefreshFailed( - "GitHub OAuth tokens do not support refresh".into(), - )) - } - - fn default_scopes(&self) -> Vec<&'static str> { - vec!["user:email", "read:user"] - } -} - -// ── URL encoding ────────────────────────────────────────────────────────────── - -fn url_encode(input: &str) -> String { - let mut out = String::new(); - for byte in input.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char); - } - b' ' => out.push('+'), - b => out.push_str(&format!("%{:02X}", b)), - } - } - out -} diff --git a/vessels/el-identity/src/session.rs b/vessels/el-identity/src/session.rs deleted file mode 100644 index 031943c..0000000 --- a/vessels/el-identity/src/session.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Session management — sessions are Engram graph nodes connected to User nodes. -//! -//! Session lifecycle: -//! 1. `SessionManager::create()` → Session node + `User ──has_session──▶ Session` edge -//! 2. `SessionManager::validate()` → find Session node, check expiry -//! 3. `SessionManager::invalidate()` → delete Session node (edges auto-removed) -//! -//! The `SessionManager` works exclusively through the `EngramClient` trait — no -//! in-memory map, no cache. The graph is the source of truth. - -use crate::{ - engram::EngramClient, - error::{IdentityError, IdentityResult}, - nodes::{Session, EDGE_HAS_SESSION, NODE_SESSION}, -}; -use std::sync::Arc; - -/// Manages session nodes in the Engram identity graph. -pub struct SessionManager { - client: Arc, - /// Default session TTL in seconds (default: 3600 = 1 hour). - pub default_ttl_seconds: i64, -} - -impl SessionManager { - pub fn new(client: Arc) -> Self { - Self { - client, - default_ttl_seconds: 3600, - } - } - - pub fn with_ttl(mut self, seconds: i64) -> Self { - self.default_ttl_seconds = seconds; - self - } - - /// Create a new session for the given user. - /// - /// Stores the Session node in Engram and creates a `has_session` edge - /// from the User node to the Session node. - /// - /// Returns the session ID string. - pub fn create( - &self, - user_id: uuid::Uuid, - ip_address: Option, - ) -> IdentityResult { - let session = Session::new(user_id, self.default_ttl_seconds, ip_address); - let session_id_str = session.id.to_string(); - - // Store session node - self.client - .create_node(NODE_SESSION, session.to_value()) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - // Edge: User → Session (has_session) - self.client - .create_edge(&user_id.to_string(), &session_id_str, EDGE_HAS_SESSION) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - Ok(session) - } - - /// Validate a session by ID. - /// - /// Returns the `Session` node if found and not expired. - /// Automatically deletes expired sessions on lookup (lazy expiry). - pub fn validate(&self, session_id: &str) -> IdentityResult { - let node = self - .client - .get_node(session_id) - .map_err(|e| IdentityError::GraphError(e.to_string()))? - .ok_or(IdentityError::SessionNotFound)?; - - let session = Session::from_value(&node) - .ok_or_else(|| IdentityError::GraphError("session node parse failed".into()))?; - - if session.is_expired() { - // Lazy cleanup — remove expired session from graph - let _ = self.client.delete_node(session_id); - return Err(IdentityError::SessionExpired); - } - - Ok(session) - } - - /// Invalidate (delete) a session node from the graph. - pub fn invalidate(&self, session_id: &str) -> IdentityResult<()> { - self.client - .delete_node(session_id) - .map_err(|e| IdentityError::GraphError(e.to_string())) - } - - /// List all sessions for a user by traversing `has_session` edges. - pub fn list_for_user(&self, user_id: &str) -> IdentityResult> { - let nodes = self - .client - .find_connected(user_id, EDGE_HAS_SESSION) - .map_err(|e| IdentityError::GraphError(e.to_string()))?; - - let sessions: Vec = nodes - .iter() - .filter_map(Session::from_value) - .filter(|s| !s.is_expired()) - .collect(); - - Ok(sessions) - } - - /// Invalidate all sessions for a user (logout everywhere). - pub fn invalidate_all_for_user(&self, user_id: &str) -> IdentityResult { - let sessions = self.list_for_user(user_id)?; - let count = sessions.len(); - for session in &sessions { - let _ = self.client.delete_node(&session.id.to_string()); - } - Ok(count) - } -} diff --git a/vessels/el-identity/src/tests.rs b/vessels/el-identity/src/tests.rs deleted file mode 100644 index 2ad1871..0000000 --- a/vessels/el-identity/src/tests.rs +++ /dev/null @@ -1,563 +0,0 @@ -//! Comprehensive tests for el-identity. -//! -//! All tests use `MockEngramClient` — no running Engram instance needed. - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use uuid::Uuid; - - use crate::{ - context::IdentityContext, - engram::{EngramClient, MockEngramClient}, - error::IdentityError, - guard::AuthGuard, - nodes::{ - OAuthToken, Role, Scope, Session, User, - EDGE_GRANTS, EDGE_HAS_ROLE, EDGE_HAS_SESSION, - NODE_USER, NODE_ROLE, NODE_SCOPE, - }, - oauth::{PkceChallenge, hash_token}, - provider::{GoogleOAuth, AppleOAuth, GithubOAuth, OAuthProvider}, - session::SessionManager, - }; - - // ── Test helpers ────────────────────────────────────────────────────────── - - fn make_client() -> Arc { - Arc::new(MockEngramClient::new()) - } - - fn make_session_manager(client: Arc) -> Arc { - Arc::new(SessionManager::new(client as Arc)) - } - - fn setup_auth_guard() -> (Arc, Arc, AuthGuard) { - let client = make_client(); - let sm = make_session_manager(client.clone()); - let guard = AuthGuard::new( - client.clone() as Arc, - sm.clone(), - ); - (client, sm, guard) - } - - fn make_user() -> User { - User::new("alice@example.com", "Alice") - } - - fn make_role_admin() -> Role { - Role::new("admin") - .with_permissions(vec!["users:read", "users:write", "orders:delete"]) - } - - fn make_role_viewer() -> Role { - Role::new("viewer") - .with_permissions(vec!["users:read", "orders:read"]) - } - - fn make_scope_email() -> Scope { - Scope::new("email", "Access email address") - } - - fn make_scope_profile() -> Scope { - Scope::new("profile", "Access profile information") - } - - // ── Node tests ──────────────────────────────────────────────────────────── - - // Test 1: User node creation and serialization round-trip - #[test] - fn test_user_node_round_trip() { - let user = User::new("bob@example.com", "Bob"); - let value = user.to_value(); - let decoded = User::from_value(&value).expect("should decode User"); - assert_eq!(decoded.email, "bob@example.com"); - assert_eq!(decoded.display_name, "Bob"); - assert_eq!(decoded.id, user.id); - } - - // Test 2: Role node with permissions serializes correctly - #[test] - fn test_role_node_permissions() { - let role = make_role_admin(); - assert!(role.has_permission("users:write")); - assert!(role.has_permission("orders:delete")); - assert!(!role.has_permission("superadmin")); - - let value = role.to_value(); - let decoded = Role::from_value(&value).expect("should decode Role"); - assert_eq!(decoded.name, "admin"); - assert_eq!(decoded.permissions.len(), 3); - } - - // Test 3: Scope node round-trip - #[test] - fn test_scope_node_round_trip() { - let scope = make_scope_email(); - let value = scope.to_value(); - let decoded = Scope::from_value(&value).expect("should decode Scope"); - assert_eq!(decoded.name, "email"); - assert_eq!(decoded.description, "Access email address"); - } - - // Test 4: Session expiry detection - #[test] - fn test_session_expiry() { - let user_id = Uuid::new_v4(); - // TTL = -1 → already expired - let session = Session::new(user_id, -1, None); - assert!(session.is_expired(), "session with negative TTL should be expired"); - - let fresh = Session::new(user_id, 3600, None); - assert!(!fresh.is_expired(), "fresh session should not be expired"); - } - - // Test 5: OAuthToken expiry detection - #[test] - fn test_oauth_token_expiry() { - use chrono::{Duration, Utc}; - let past = Utc::now() - Duration::seconds(1); - let token = OAuthToken::new("google", "hash123", None, past, vec![]); - assert!(token.is_expired()); - - let future = Utc::now() + Duration::seconds(3600); - let fresh = OAuthToken::new("google", "hash456", None, future, vec!["email".into()]); - assert!(!fresh.is_expired()); - assert!(fresh.has_scope("email")); - assert!(!fresh.has_scope("openid")); - } - - // ── MockEngramClient tests ───────────────────────────────────────────────── - - // Test 6: MockEngramClient create and retrieve node - #[test] - fn test_mock_client_create_and_get() { - let client = make_client(); - let user = make_user(); - let id = client.create_node(NODE_USER, user.to_value()).unwrap(); - assert_eq!(id, user.id.to_string()); - - let retrieved = client.get_node(&id).unwrap().expect("should find node"); - let decoded = User::from_value(&retrieved).expect("should decode"); - assert_eq!(decoded.email, user.email); - } - - // Test 7: MockEngramClient get_node returns None for unknown ID - #[test] - fn test_mock_client_get_unknown_returns_none() { - let client = make_client(); - let result = client.get_node("nonexistent-id").unwrap(); - assert!(result.is_none()); - } - - // Test 8: MockEngramClient find_nodes with query - #[test] - fn test_mock_client_find_nodes() { - let client = make_client(); - let u1 = User::new("alice@example.com", "Alice"); - let u2 = User::new("bob@example.com", "Bob"); - client.create_node(NODE_USER, u1.to_value()).unwrap(); - client.create_node(NODE_USER, u2.to_value()).unwrap(); - - let query = serde_json::json!({"email": "alice@example.com"}); - let results = client.find_nodes(NODE_USER, query).unwrap(); - assert_eq!(results.len(), 1); - let found = User::from_value(&results[0]).unwrap(); - assert_eq!(found.display_name, "Alice"); - } - - // Test 9: MockEngramClient delete_node removes node and edges - #[test] - fn test_mock_client_delete_node() { - let client = make_client(); - let user = make_user(); - let role = make_role_admin(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - client.create_node(NODE_ROLE, role.to_value()).unwrap(); - client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); - assert_eq!(client.count_edges(EDGE_HAS_ROLE), 1); - - client.delete_node(&user.id.to_string()).unwrap(); - assert!(client.get_node(&user.id.to_string()).unwrap().is_none()); - // Edge should also be gone - assert_eq!(client.count_edges(EDGE_HAS_ROLE), 0); - } - - // Test 10: MockEngramClient find_connected traverses edges - #[test] - fn test_mock_client_find_connected() { - let client = make_client(); - let user = make_user(); - let role1 = make_role_admin(); - let role2 = make_role_viewer(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - client.create_node(NODE_ROLE, role1.to_value()).unwrap(); - client.create_node(NODE_ROLE, role2.to_value()).unwrap(); - client.create_edge(&user.id.to_string(), &role1.id.to_string(), EDGE_HAS_ROLE).unwrap(); - client.create_edge(&user.id.to_string(), &role2.id.to_string(), EDGE_HAS_ROLE).unwrap(); - - let roles = client.find_connected(&user.id.to_string(), EDGE_HAS_ROLE).unwrap(); - assert_eq!(roles.len(), 2); - } - - // ── SessionManager tests ─────────────────────────────────────────────────── - - // Test 11: SessionManager creates session and connects to user - #[test] - fn test_session_manager_create() { - let client = make_client(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - - let sm = make_session_manager(client.clone()); - let session = sm.create(user.id, Some("127.0.0.1".into())).unwrap(); - assert_eq!(session.user_id, user.id); - assert_eq!(session.ip_address, Some("127.0.0.1".to_string())); - - // Should have created a has_session edge - assert_eq!(client.count_edges(EDGE_HAS_SESSION), 1); - } - - // Test 12: SessionManager validate succeeds for fresh session - #[test] - fn test_session_manager_validate_fresh() { - let client = make_client(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - let sm = make_session_manager(client.clone()); - let session = sm.create(user.id, None).unwrap(); - - let validated = sm.validate(&session.id.to_string()).unwrap(); - assert_eq!(validated.id, session.id); - } - - // Test 13: SessionManager validate fails for expired session - #[test] - fn test_session_manager_validate_expired() { - let client = make_client(); - let sm = Arc::new(SessionManager::new(client.clone() as Arc).with_ttl(-1)); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - - let session = sm.create(user.id, None).unwrap(); - let result = sm.validate(&session.id.to_string()); - assert!(matches!(result, Err(IdentityError::SessionExpired))); - } - - // Test 14: SessionManager validate fails for missing session - #[test] - fn test_session_manager_validate_missing() { - let client = make_client(); - let sm = make_session_manager(client.clone()); - let result = sm.validate("nonexistent-session-id"); - assert!(matches!(result, Err(IdentityError::SessionNotFound))); - } - - // Test 15: SessionManager invalidate removes session - #[test] - fn test_session_manager_invalidate() { - let client = make_client(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - let sm = make_session_manager(client.clone()); - let session = sm.create(user.id, None).unwrap(); - let session_id = session.id.to_string(); - - sm.invalidate(&session_id).unwrap(); - assert!(matches!(sm.validate(&session_id), Err(IdentityError::SessionNotFound))); - } - - // Test 16: SessionManager list_for_user returns active sessions - #[test] - fn test_session_manager_list_for_user() { - let client = make_client(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - let sm = make_session_manager(client.clone()); - - sm.create(user.id, Some("1.1.1.1".into())).unwrap(); - sm.create(user.id, Some("2.2.2.2".into())).unwrap(); - - let sessions = sm.list_for_user(&user.id.to_string()).unwrap(); - assert_eq!(sessions.len(), 2); - } - - // ── AuthGuard tests ─────────────────────────────────────────────────────── - - // Test 17: AuthGuard authenticates user with role and scope - #[test] - fn test_auth_guard_full_resolution() { - let (client, sm, guard) = setup_auth_guard(); - - let user = make_user(); - let role = make_role_admin(); - let scope = make_scope_email(); - - // Register user, role, scope, and wire edges - client.create_node(NODE_USER, user.to_value()).unwrap(); - client.create_node(NODE_ROLE, role.to_value()).unwrap(); - client.create_node(NODE_SCOPE, scope.to_value()).unwrap(); - client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); - client.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS).unwrap(); - - // Create session - let session = sm.create(user.id, None).unwrap(); - - // Authenticate - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - assert_eq!(ctx.user.email, "alice@example.com"); - assert_eq!(ctx.roles.len(), 1); - assert!(ctx.has_role("admin")); - assert!(ctx.has_scope("email")); - assert!(ctx.has_permission("users:write")); - } - - // Test 18: AuthGuard rejects unknown session - #[test] - fn test_auth_guard_rejects_unknown_session() { - let (_, _, guard) = setup_auth_guard(); - let result = guard.authenticate("no-such-session"); - assert!(matches!(result, Err(IdentityError::SessionNotFound))); - } - - // Test 19: AuthGuard require_role succeeds for correct role - #[test] - fn test_auth_guard_require_role_success() { - let (client, sm, guard) = setup_auth_guard(); - let user = make_user(); - let role = make_role_admin(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - client.create_node(NODE_ROLE, role.to_value()).unwrap(); - client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap(); - let session = sm.create(user.id, None).unwrap(); - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - - assert!(guard.require_role(&ctx, "admin").is_ok()); - } - - // Test 20: AuthGuard require_role fails for missing role - #[test] - fn test_auth_guard_require_role_forbidden() { - let (client, sm, guard) = setup_auth_guard(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - let session = sm.create(user.id, None).unwrap(); - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - - let result = guard.require_role(&ctx, "superadmin"); - assert!(matches!(result, Err(IdentityError::Forbidden(_)))); - } - - // Test 21: AuthGuard require_scope fails for missing scope - #[test] - fn test_auth_guard_require_scope_forbidden() { - let (client, sm, guard) = setup_auth_guard(); - let user = make_user(); - client.create_node(NODE_USER, user.to_value()).unwrap(); - let session = sm.create(user.id, None).unwrap(); - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - - let result = guard.require_scope(&ctx, "admin:write"); - assert!(matches!(result, Err(IdentityError::ScopeForbidden(_)))); - } - - // Test 22: AuthGuard register_user and assign_role - #[test] - fn test_auth_guard_register_and_assign_role() { - let (_client, sm, guard) = setup_auth_guard(); - let user = make_user(); - let role = make_role_viewer(); - - guard.register_user(&user).unwrap(); - guard.assign_role(&user.id.to_string(), &role).unwrap(); - - let session = sm.create(user.id, None).unwrap(); - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - assert!(ctx.has_role("viewer")); - assert!(ctx.has_permission("users:read")); - assert!(!ctx.has_permission("orders:delete")); - } - - // ── PKCE tests ──────────────────────────────────────────────────────────── - - // Test 23: PkceChallenge generates valid verifier/challenge pair - #[test] - fn test_pkce_challenge_generates_valid_pair() { - let pkce = PkceChallenge::generate(); - assert!(!pkce.verifier.is_empty(), "verifier should not be empty"); - assert!(!pkce.challenge.is_empty(), "challenge should not be empty"); - assert_ne!(pkce.verifier, pkce.challenge, "verifier and challenge must differ"); - assert_eq!(pkce.method, "S256"); - } - - // Test 24: PkceChallenge verify matches correct verifier - #[test] - fn test_pkce_challenge_verify_correct() { - let pkce = PkceChallenge::generate(); - assert!(pkce.verify(&pkce.verifier.clone()), "should verify correct verifier"); - } - - // Test 25: PkceChallenge verify rejects wrong verifier - #[test] - fn test_pkce_challenge_verify_rejects_wrong() { - let pkce = PkceChallenge::generate(); - assert!(!pkce.verify("wrong-verifier-value"), "should reject wrong verifier"); - } - - // Test 26: hash_token is deterministic - #[test] - fn test_hash_token_deterministic() { - let token = "my-secret-access-token"; - let h1 = hash_token(token); - let h2 = hash_token(token); - assert_eq!(h1, h2, "hash must be deterministic"); - assert_ne!(h1, token, "hash must differ from input"); - assert_eq!(h1.len(), 64, "SHA-256 hex output is 64 chars"); - } - - // ── Provider tests ──────────────────────────────────────────────────────── - - // Test 27: GoogleOAuth builds correct authorization URL - #[test] - fn test_google_oauth_authorization_url() { - let provider = GoogleOAuth::new("client-id-123", "secret"); - let url = provider.authorization_url( - "https://myapp.com/callback", - "pkce-challenge-abc", - "state-xyz", - &[], - ); - assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth")); - assert!(url.contains("client_id=client-id-123")); - assert!(url.contains("code_challenge=pkce-challenge-abc")); - assert!(url.contains("code_challenge_method=S256")); - assert!(url.contains("state=state-xyz")); - assert!(url.contains("access_type=offline"), "Google needs offline for refresh tokens"); - } - - // Test 28: GoogleOAuth default scopes include openid, email, profile - #[test] - fn test_google_oauth_default_scopes() { - let provider = GoogleOAuth::new("id", "secret"); - let scopes = provider.default_scopes(); - assert!(scopes.contains(&"openid")); - assert!(scopes.contains(&"email")); - assert!(scopes.contains(&"profile")); - } - - // Test 29: AppleOAuth builds correct authorization URL with form_post - #[test] - fn test_apple_oauth_authorization_url() { - let provider = AppleOAuth::new("com.example.app", "jwt-secret", "TEAMID"); - let url = provider.authorization_url( - "https://myapp.com/callback", - "challenge", - "state", - &[], - ); - assert!(url.starts_with("https://appleid.apple.com/auth/authorize")); - assert!(url.contains("response_mode=form_post"), "Apple requires form_post"); - } - - // Test 30: GithubOAuth ignores PKCE in URL (GitHub doesn't support it) - #[test] - fn test_github_oauth_no_pkce_in_url() { - let provider = GithubOAuth::new("gh-client-id", "gh-secret"); - let url = provider.authorization_url( - "https://myapp.com/callback", - "pkce-challenge-ignored", - "state", - &[], - ); - assert!(url.starts_with("https://github.com/login/oauth/authorize")); - assert!(!url.contains("code_challenge"), "GitHub doesn't support PKCE"); - assert!(url.contains("scope=user%3Aemail+read%3Auser") || url.contains("scope=")); - } - - // Test 31: GithubOAuth refresh_token returns error - #[test] - fn test_github_oauth_refresh_returns_error() { - let provider = GithubOAuth::new("id", "secret"); - let result = provider.refresh_token("some-refresh-token"); - assert!(matches!(result, Err(IdentityError::TokenRefreshFailed(_)))); - } - - // ── IdentityContext tests ───────────────────────────────────────────────── - - // Test 32: IdentityContext role_names and scope_names - #[test] - fn test_identity_context_names() { - let user = make_user(); - let session = Session::new(user.id, 3600, None); - let roles = vec![make_role_admin(), make_role_viewer()]; - let scopes = vec![make_scope_email(), make_scope_profile()]; - - let ctx = IdentityContext::new(user.clone(), session, roles, scopes); - let mut role_names = ctx.role_names(); - role_names.sort(); - assert_eq!(role_names, vec!["admin", "viewer"]); - - let mut scope_names = ctx.scope_names(); - scope_names.sort(); - assert_eq!(scope_names, vec!["email", "profile"]); - } - - // Test 33: IdentityContext has_permission across multiple roles - #[test] - fn test_identity_context_permission_across_roles() { - let user = make_user(); - let session = Session::new(user.id, 3600, None); - let roles = vec![make_role_viewer()]; // viewer has users:read and orders:read - let ctx = IdentityContext::new(user, session, roles, vec![]); - - assert!(ctx.has_permission("users:read")); - assert!(ctx.has_permission("orders:read")); - assert!(!ctx.has_permission("users:write")); - assert!(!ctx.has_permission("orders:delete")); - } - - // Test 34: AuthGuard assign_scope_to_role wires scope into context - #[test] - fn test_auth_guard_assign_scope_to_role() { - let (_client, sm, guard) = setup_auth_guard(); - let user = make_user(); - let role = make_role_admin(); - let scope = make_scope_profile(); - - guard.register_user(&user).unwrap(); - guard.assign_role(&user.id.to_string(), &role).unwrap(); - guard.assign_scope_to_role(&role, &scope).unwrap(); - - let session = sm.create(user.id, None).unwrap(); - let ctx = guard.authenticate(&session.id.to_string()).unwrap(); - assert!(ctx.has_scope("profile")); - } - - // Test 35: Two different users have independent sessions - #[test] - fn test_independent_user_sessions() { - let (client, sm, guard) = setup_auth_guard(); - - let alice = User::new("alice@example.com", "Alice"); - let bob = User::new("bob@example.com", "Bob"); - let admin_role = make_role_admin(); - - client.create_node(NODE_USER, alice.to_value()).unwrap(); - client.create_node(NODE_USER, bob.to_value()).unwrap(); - client.create_node(NODE_ROLE, admin_role.to_value()).unwrap(); - // Only Alice gets the admin role - client.create_edge(&alice.id.to_string(), &admin_role.id.to_string(), EDGE_HAS_ROLE).unwrap(); - - let alice_session = sm.create(alice.id, None).unwrap(); - let bob_session = sm.create(bob.id, None).unwrap(); - - let alice_ctx = guard.authenticate(&alice_session.id.to_string()).unwrap(); - let bob_ctx = guard.authenticate(&bob_session.id.to_string()).unwrap(); - - assert!(alice_ctx.has_role("admin"), "Alice should have admin"); - assert!(!bob_ctx.has_role("admin"), "Bob should not have admin"); - assert_eq!(alice_ctx.email(), "alice@example.com"); - assert_eq!(bob_ctx.email(), "bob@example.com"); - } -} diff --git a/vessels/el-layout/Cargo.toml b/vessels/el-layout/Cargo.toml deleted file mode 100644 index bf74a21..0000000 --- a/vessels/el-layout/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "el-layout" -version = "0.1.0" -edition = "2021" -description = "el-ui responsive layout engine — responsive by default, zero breakpoints" -license = "MIT" - -[lib] -name = "el_layout" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" -el-style = { path = "../el-style" } - -[dev-dependencies] diff --git a/vessels/el-layout/src/breakpoint.rs b/vessels/el-layout/src/breakpoint.rs deleted file mode 100644 index ce45afe..0000000 --- a/vessels/el-layout/src/breakpoint.rs +++ /dev/null @@ -1,98 +0,0 @@ -/// Breakpoints — named viewport width thresholds. -/// -/// These are provided for the rare cases where you need explicit breakpoint -/// logic. For most cases, use the automatic layout in VStack/HStack/Grid. - -/// Named breakpoint sizes (in dp/logical pixels). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Breakpoint { - /// Default — any width (the base / mobile-first value). - Base, - /// Small — 640dp+ (large phones, landscape phones). - Sm, - /// Medium — 768dp+ (tablets, small laptops). - Md, - /// Large — 1024dp+ (laptops, most desktops). - Lg, - /// Extra large — 1280dp+ (large desktops, wide monitors). - Xl, -} - -impl Breakpoint { - /// The minimum width (dp) at which this breakpoint activates. - pub fn min_width(&self) -> f32 { - match self { - Breakpoint::Base => 0.0, - Breakpoint::Sm => 640.0, - Breakpoint::Md => 768.0, - Breakpoint::Lg => 1024.0, - Breakpoint::Xl => 1280.0, - } - } - - /// Classify a container width into its active breakpoint. - pub fn for_width(width: f32) -> Self { - if width >= 1280.0 { - Breakpoint::Xl - } else if width >= 1024.0 { - Breakpoint::Lg - } else if width >= 768.0 { - Breakpoint::Md - } else if width >= 640.0 { - Breakpoint::Sm - } else { - Breakpoint::Base - } - } - - /// True if this breakpoint is at least as wide as `other`. - pub fn at_least(&self, other: Breakpoint) -> bool { - self >= &other - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn breakpoint_for_small_width() { - assert_eq!(Breakpoint::for_width(320.0), Breakpoint::Base); - } - - #[test] - fn breakpoint_for_tablet_width() { - assert_eq!(Breakpoint::for_width(800.0), Breakpoint::Md); - } - - #[test] - fn breakpoint_for_desktop_width() { - assert_eq!(Breakpoint::for_width(1440.0), Breakpoint::Xl); - } - - #[test] - fn breakpoint_ordering() { - assert!(Breakpoint::Xl > Breakpoint::Base); - assert!(Breakpoint::Lg > Breakpoint::Sm); - } - - #[test] - fn at_least() { - assert!(Breakpoint::Lg.at_least(Breakpoint::Md)); - assert!(!Breakpoint::Sm.at_least(Breakpoint::Md)); - } - - #[test] - fn min_widths_ascending() { - let bps = [ - Breakpoint::Base, - Breakpoint::Sm, - Breakpoint::Md, - Breakpoint::Lg, - Breakpoint::Xl, - ]; - for i in 1..bps.len() { - assert!(bps[i].min_width() > bps[i - 1].min_width()); - } - } -} diff --git a/vessels/el-layout/src/constraints.rs b/vessels/el-layout/src/constraints.rs deleted file mode 100644 index 16f94cf..0000000 --- a/vessels/el-layout/src/constraints.rs +++ /dev/null @@ -1,162 +0,0 @@ -/// Layout constraints — what the parent is offering the child. -/// -/// A parent passes a LayoutConstraints to each child during layout. -/// The child must produce a size within these constraints. -/// Constraints flow down; sizes flow back up. - -/// Constraints passed from a parent to a child during layout. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct LayoutConstraints { - /// Minimum width the child must be (dp). - pub min_width: f32, - /// Maximum width available to the child (dp). f32::INFINITY = unbounded. - pub max_width: f32, - /// Minimum height the child must be (dp). - pub min_height: f32, - /// Maximum height available to the child (dp). f32::INFINITY = unbounded. - pub max_height: f32, -} - -impl LayoutConstraints { - /// Unconstrained — the child can be any size. - pub fn unbounded() -> Self { - Self { - min_width: 0.0, - max_width: f32::INFINITY, - min_height: 0.0, - max_height: f32::INFINITY, - } - } - - /// Constrained to a specific width, unconstrained height. - pub fn with_max_width(max_width: f32) -> Self { - Self { - min_width: 0.0, - max_width, - min_height: 0.0, - max_height: f32::INFINITY, - } - } - - /// Constrained to an exact width and height. - pub fn tight(width: f32, height: f32) -> Self { - Self { - min_width: width, - max_width: width, - min_height: height, - max_height: height, - } - } - - /// Return constraints loosened to allow any size up to the maximums. - pub fn loosen(&self) -> Self { - Self { - min_width: 0.0, - max_width: self.max_width, - min_height: 0.0, - max_height: self.max_height, - } - } - - /// Deflate the constraints by padding amounts. - /// Useful when a parent applies its own padding before offering space to a child. - pub fn deflate(&self, horizontal: f32, vertical: f32) -> Self { - Self { - min_width: (self.min_width - horizontal).max(0.0), - max_width: (self.max_width - horizontal).max(0.0), - min_height: (self.min_height - vertical).max(0.0), - max_height: (self.max_height - vertical).max(0.0), - } - } - - /// Is the width dimension bounded? - pub fn has_bounded_width(&self) -> bool { - self.max_width.is_finite() - } - - /// Is the height dimension bounded? - pub fn has_bounded_height(&self) -> bool { - self.max_height.is_finite() - } - - /// Clamp a proposed size to fit within these constraints. - pub fn clamp_size(&self, width: f32, height: f32) -> (f32, f32) { - ( - width.clamp(self.min_width, self.max_width), - height.clamp(self.min_height, self.max_height), - ) - } -} - -/// The size a child reports back to its parent after layout. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Size { - pub width: f32, - pub height: f32, -} - -impl Size { - pub fn new(width: f32, height: f32) -> Self { - Self { width, height } - } - - pub fn zero() -> Self { - Self { width: 0.0, height: 0.0 } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unbounded_has_no_max() { - let c = LayoutConstraints::unbounded(); - assert!(c.max_width.is_infinite()); - assert!(c.max_height.is_infinite()); - } - - #[test] - fn tight_constraints() { - let c = LayoutConstraints::tight(100.0, 50.0); - assert_eq!(c.min_width, 100.0); - assert_eq!(c.max_width, 100.0); - } - - #[test] - fn deflate_reduces_available_space() { - let c = LayoutConstraints::tight(200.0, 100.0); - let deflated = c.deflate(16.0, 8.0); - assert_eq!(deflated.max_width, 184.0); - assert_eq!(deflated.max_height, 92.0); - } - - #[test] - fn deflate_does_not_go_negative() { - let c = LayoutConstraints::tight(10.0, 10.0); - let deflated = c.deflate(20.0, 20.0); - assert_eq!(deflated.max_width, 0.0); - assert_eq!(deflated.max_height, 0.0); - } - - #[test] - fn clamp_size() { - let c = LayoutConstraints { - min_width: 50.0, - max_width: 200.0, - min_height: 30.0, - max_height: 100.0, - }; - let (w, h) = c.clamp_size(250.0, 20.0); - assert_eq!(w, 200.0); - assert_eq!(h, 30.0); - } - - #[test] - fn bounded_width_detection() { - let bounded = LayoutConstraints::with_max_width(400.0); - let unbounded = LayoutConstraints::unbounded(); - assert!(bounded.has_bounded_width()); - assert!(!unbounded.has_bounded_width()); - } -} diff --git a/vessels/el-layout/src/flex.rs b/vessels/el-layout/src/flex.rs deleted file mode 100644 index 7217b20..0000000 --- a/vessels/el-layout/src/flex.rs +++ /dev/null @@ -1,178 +0,0 @@ -/// FlexLayout — the underlying flex engine for VStack, HStack. -/// -/// This is the power behind the stacks. Most developers use VStack/HStack -/// directly; FlexLayout is available when you need full control. - -/// Direction of the flex axis. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FlexDirection { - /// Children arranged top-to-bottom (VStack). - Column, - /// Children arranged left-to-right (HStack) — respects RTL automatically. - Row, - /// Like Column, but children wrap to new columns when they overflow. - ColumnWrap, - /// Like Row, but children wrap to new rows when they overflow. - RowWrap, -} - -impl FlexDirection { - pub fn is_horizontal(&self) -> bool { - matches!(self, FlexDirection::Row | FlexDirection::RowWrap) - } - - pub fn is_vertical(&self) -> bool { - matches!(self, FlexDirection::Column | FlexDirection::ColumnWrap) - } - - pub fn wraps(&self) -> bool { - matches!( - self, - FlexDirection::ColumnWrap | FlexDirection::RowWrap - ) - } -} - -/// Alignment along the cross axis. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CrossAxisAlignment { - /// Stretch children to fill the cross axis. - Stretch, - /// Align children to the start of the cross axis. - Start, - /// Center children on the cross axis. - Center, - /// Align children to the end of the cross axis. - End, - /// Align text baselines (horizontal stacks only). - Baseline, -} - -/// Alignment along the main axis. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MainAxisAlignment { - /// Pack children toward the start. - Start, - /// Center children. - Center, - /// Pack children toward the end. - End, - /// Distribute space between children. - SpaceBetween, - /// Distribute space around children. - SpaceAround, - /// Distribute space evenly around children. - SpaceEvenly, -} - -/// How much space the main axis should occupy. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MainAxisSize { - /// Shrink to minimum size needed. - Min, - /// Expand to fill all available space. - Max, -} - -/// Logical horizontal alignment (RTL-aware). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HAlign { - /// Toward the reading-start (left in LTR, right in RTL). - Leading, - Center, - /// Toward the reading-end (right in LTR, left in RTL). - Trailing, -} - -/// Vertical alignment. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VAlign { - Top, - Center, - Bottom, - Baseline, -} - -/// Full flex layout specification. -#[derive(Debug, Clone)] -pub struct FlexLayout { - pub direction: FlexDirection, - pub main_axis_alignment: MainAxisAlignment, - pub cross_axis_alignment: CrossAxisAlignment, - pub main_axis_size: MainAxisSize, - /// Gap between children in dp. - pub gap: u32, -} - -impl FlexLayout { - /// VStack defaults (column, wrapping, leading-aligned). - pub fn vstack(spacing: u32, wrap: bool) -> Self { - Self { - direction: if wrap { - FlexDirection::ColumnWrap - } else { - FlexDirection::Column - }, - main_axis_alignment: MainAxisAlignment::Start, - cross_axis_alignment: CrossAxisAlignment::Stretch, - main_axis_size: MainAxisSize::Max, - gap: spacing, - } - } - - /// HStack defaults (row, wrapping, center-aligned vertically). - pub fn hstack(spacing: u32, wrap: bool) -> Self { - Self { - direction: if wrap { - FlexDirection::RowWrap - } else { - FlexDirection::Row - }, - main_axis_alignment: MainAxisAlignment::Start, - cross_axis_alignment: CrossAxisAlignment::Center, - main_axis_size: MainAxisSize::Max, - gap: spacing, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn flex_direction_horizontal() { - assert!(FlexDirection::Row.is_horizontal()); - assert!(FlexDirection::RowWrap.is_horizontal()); - assert!(!FlexDirection::Column.is_horizontal()); - } - - #[test] - fn flex_direction_vertical() { - assert!(FlexDirection::Column.is_vertical()); - assert!(!FlexDirection::Row.is_vertical()); - } - - #[test] - fn flex_direction_wraps() { - assert!(FlexDirection::RowWrap.wraps()); - assert!(!FlexDirection::Row.wraps()); - } - - #[test] - fn vstack_layout_defaults() { - let layout = FlexLayout::vstack(8, true); - assert!(layout.direction.is_vertical()); - assert!(layout.direction.wraps()); - assert_eq!(layout.gap, 8); - } - - #[test] - fn hstack_layout_defaults() { - let layout = FlexLayout::hstack(16, false); - assert!(layout.direction.is_horizontal()); - assert!(!layout.direction.wraps()); - assert_eq!(layout.gap, 16); - assert_eq!(layout.cross_axis_alignment, CrossAxisAlignment::Center); - } -} diff --git a/vessels/el-layout/src/grid.rs b/vessels/el-layout/src/grid.rs deleted file mode 100644 index c6613ea..0000000 --- a/vessels/el-layout/src/grid.rs +++ /dev/null @@ -1,187 +0,0 @@ -/// GridLayout — a responsive column grid. -/// -/// The auto column mode (GridColumns::Auto) automatically computes how many -/// columns fit given a minimum column width. No breakpoints needed. -/// Fixed column count (GridColumns::Fixed) puts exactly N columns in a row. - -use el_style::modifier::{StyleModifier, StyleSet}; - -/// How to determine the number of grid columns. -#[derive(Debug, Clone, PartialEq)] -pub enum GridColumns { - /// A fixed number of equally-wide columns. - Fixed(u32), - /// As many columns as fit with each column at least `min_width` dp wide. - /// This is how you get responsive grids without breakpoints. - Auto { min_width: f32 }, -} - -impl GridColumns { - /// Compute the actual column count given a container width. - pub fn count_for_width(&self, container_width: f32) -> u32 { - match self { - GridColumns::Fixed(n) => *n, - GridColumns::Auto { min_width } => { - if container_width <= 0.0 || *min_width <= 0.0 { - return 1; - } - let cols = (container_width / min_width).floor() as u32; - cols.max(1) - } - } - } - - /// Compute the width of each column given container width and gap. - pub fn column_width(&self, container_width: f32, gap: f32) -> f32 { - let cols = self.count_for_width(container_width) as f32; - let total_gap = gap * (cols - 1.0).max(0.0); - ((container_width - total_gap) / cols).max(0.0) - } -} - -/// Row height specification. -#[derive(Debug, Clone, PartialEq)] -pub enum GridRows { - /// All rows are the same height (dp). - Fixed(f32), - /// Rows take the height of their tallest item. - Auto, -} - -/// A responsive grid layout. -#[derive(Debug, Clone)] -pub struct GridLayout { - pub columns: GridColumns, - pub rows: GridRows, - /// Horizontal gap between columns (dp). - pub column_gap: u32, - /// Vertical gap between rows (dp). - pub row_gap: u32, - pub style: StyleSet, -} - -impl Default for GridLayout { - fn default() -> Self { - Self { - columns: GridColumns::Auto { min_width: 200.0 }, - rows: GridRows::Auto, - column_gap: 16, - row_gap: 16, - style: StyleSet::default(), - } - } -} - -impl GridLayout { - pub fn new() -> Self { - Self::default() - } - - /// Set a fixed column count. - pub fn columns_fixed(mut self, n: u32) -> Self { - self.columns = GridColumns::Fixed(n); - self - } - - /// Set auto columns with a minimum column width. - pub fn columns_auto(mut self, min_width: f32) -> Self { - self.columns = GridColumns::Auto { min_width }; - self - } - - /// Set gap (same for both axes). - pub fn gap(mut self, dp: u32) -> Self { - self.column_gap = dp; - self.row_gap = dp; - self - } - - /// Set column and row gaps separately. - pub fn gap_xy(mut self, column_gap: u32, row_gap: u32) -> Self { - self.column_gap = column_gap; - self.row_gap = row_gap; - self - } - - /// How many columns are active at a given container width? - pub fn active_columns(&self, container_width: f32) -> u32 { - self.columns.count_for_width(container_width) - } -} - -impl StyleModifier for GridLayout { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fixed_columns_always_returns_n() { - let cols = GridColumns::Fixed(3); - assert_eq!(cols.count_for_width(100.0), 3); - assert_eq!(cols.count_for_width(2000.0), 3); - } - - #[test] - fn auto_columns_small_container() { - let cols = GridColumns::Auto { min_width: 200.0 }; - assert_eq!(cols.count_for_width(300.0), 1); - } - - #[test] - fn auto_columns_medium_container() { - let cols = GridColumns::Auto { min_width: 200.0 }; - assert_eq!(cols.count_for_width(500.0), 2); - } - - #[test] - fn auto_columns_wide_container() { - let cols = GridColumns::Auto { min_width: 200.0 }; - assert_eq!(cols.count_for_width(1200.0), 6); - } - - #[test] - fn auto_columns_minimum_one() { - let cols = GridColumns::Auto { min_width: 500.0 }; - assert_eq!(cols.count_for_width(100.0), 1); - } - - #[test] - fn column_width_with_gap() { - let cols = GridColumns::Fixed(3); - // 300px wide, 3 cols, 16px gap between each = 2 gaps - // (300 - 32) / 3 = 268/3 ≈ 89.33 - let w = cols.column_width(300.0, 16.0); - assert!((w - (300.0 - 32.0) / 3.0).abs() < 0.01); - } - - #[test] - fn grid_defaults() { - let grid = GridLayout::new(); - assert_eq!(grid.column_gap, 16); - assert_eq!(grid.row_gap, 16); - } - - #[test] - fn grid_active_columns_auto() { - let grid = GridLayout::new().columns_auto(200.0); - assert_eq!(grid.active_columns(600.0), 3); - } - - #[test] - fn grid_active_columns_fixed() { - let grid = GridLayout::new().columns_fixed(4); - assert_eq!(grid.active_columns(100.0), 4); - } - - #[test] - fn grid_gap_xy() { - let grid = GridLayout::new().gap_xy(8, 24); - assert_eq!(grid.column_gap, 8); - assert_eq!(grid.row_gap, 24); - } -} diff --git a/vessels/el-layout/src/lib.rs b/vessels/el-layout/src/lib.rs deleted file mode 100644 index 76f2115..0000000 --- a/vessels/el-layout/src/lib.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! el-layout — Responsive layout engine for el-ui. -//! -//! **Responsive by default.** VStack and HStack wrap automatically. -//! Grid uses auto columns. You don't write breakpoints for basic layouts. -//! -//! ## Layout primitives -//! -//! - [`VStack`] — vertical stack, wraps by default -//! - [`HStack`] — horizontal stack, wraps by default, RTL-aware -//! - [`ZStack`] — depth stack, children overlap -//! - [`GridLayout`] — responsive grid, auto or fixed columns -//! - [`ScrollView`] — scrollable container -//! -//! ## Responsive values -//! -//! For the rare case where you need a value to change at a specific breakpoint: -//! ``` -//! use el_layout::prelude::*; -//! -//! // Most specific value that applies cascades down to less specific -//! let cols: Responsive = Responsive::fixed(1).md(2).lg(3); -//! assert_eq!(*cols.resolve(Breakpoint::Sm), 1); -//! assert_eq!(*cols.resolve(Breakpoint::Md), 2); -//! assert_eq!(*cols.resolve(Breakpoint::Lg), 3); -//! assert_eq!(*cols.resolve(Breakpoint::Xl), 3); // cascades from lg -//! ``` - -#![deny(warnings)] - -pub mod breakpoint; -pub mod constraints; -pub mod flex; -pub mod grid; -pub mod platform; -pub mod responsive; -pub mod scroll; -pub mod stack; - -pub mod prelude { - pub use crate::breakpoint::Breakpoint; - pub use crate::constraints::{LayoutConstraints, Size}; - pub use crate::flex::{CrossAxisAlignment, FlexDirection, FlexLayout, HAlign, MainAxisAlignment, MainAxisSize, VAlign}; - pub use crate::grid::{GridColumns, GridLayout, GridRows}; - pub use crate::platform::{PlatformFamily, PlatformSizing, SafeAreaInsets}; - pub use crate::responsive::Responsive; - pub use crate::scroll::{ScrollAxis, ScrollIndicator, ScrollView}; - pub use crate::stack::{HStack, Spacer, VStack, ZStack}; -} - -pub use prelude::*; diff --git a/vessels/el-layout/src/platform.rs b/vessels/el-layout/src/platform.rs deleted file mode 100644 index f99a94f..0000000 --- a/vessels/el-layout/src/platform.rs +++ /dev/null @@ -1,197 +0,0 @@ -/// Platform-aware sizing constants. -/// -/// Platform HIG minimum touch targets, safe area handling, and -/// density-independent pixel conventions. - -/// Which platform family the app is running on. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PlatformFamily { - /// iOS / iPadOS - Ios, - /// Android - Android, - /// macOS - Macos, - /// Windows - Windows, - /// Linux desktop - Linux, - /// Web browser - Web, -} - -/// Platform-specific sizing constants. -#[derive(Debug, Clone)] -pub struct PlatformSizing { - /// Minimum touch target dimension in dp (height and width). - pub min_touch_target: f32, - /// Standard icon size in dp. - pub icon_size: f32, - /// Standard small icon size in dp. - pub icon_size_sm: f32, - /// Standard navigation bar height in dp. - pub nav_bar_height: f32, - /// Standard tab bar height in dp. - pub tab_bar_height: f32, - /// Standard status bar height in dp (approximate; actual is platform-provided). - pub status_bar_height: f32, -} - -impl PlatformSizing { - /// Sizing constants for a given platform. - pub fn for_platform(platform: PlatformFamily) -> Self { - match platform { - PlatformFamily::Ios => Self { - min_touch_target: 44.0, // Apple HIG - icon_size: 24.0, - icon_size_sm: 16.0, - nav_bar_height: 44.0, - tab_bar_height: 49.0, - status_bar_height: 44.0, // approximate; varies with notch - }, - PlatformFamily::Android => Self { - min_touch_target: 48.0, // Material Design - icon_size: 24.0, - icon_size_sm: 18.0, - nav_bar_height: 56.0, - tab_bar_height: 56.0, - status_bar_height: 24.0, - }, - PlatformFamily::Macos => Self { - min_touch_target: 44.0, // macOS HIG - icon_size: 16.0, - icon_size_sm: 12.0, - nav_bar_height: 28.0, - tab_bar_height: 36.0, - status_bar_height: 0.0, // macOS status bar is system chrome - }, - PlatformFamily::Windows => Self { - min_touch_target: 44.0, - icon_size: 16.0, - icon_size_sm: 12.0, - nav_bar_height: 40.0, - tab_bar_height: 40.0, - status_bar_height: 0.0, - }, - PlatformFamily::Linux => Self { - min_touch_target: 44.0, - icon_size: 16.0, - icon_size_sm: 12.0, - nav_bar_height: 36.0, - tab_bar_height: 36.0, - status_bar_height: 0.0, - }, - PlatformFamily::Web => Self { - min_touch_target: 44.0, // WCAG 2.5.5 recommended - icon_size: 20.0, - icon_size_sm: 16.0, - nav_bar_height: 64.0, - tab_bar_height: 48.0, - status_bar_height: 0.0, - }, - } - } - - /// Is the given width adequate for a touch target? - pub fn is_touch_adequate(&self, width: f32, height: f32) -> bool { - width >= self.min_touch_target && height >= self.min_touch_target - } -} - -/// Safe area insets — space reserved by system chrome. -/// -/// These are provided at runtime by the platform. The values here -/// are conservative defaults for simulation. -#[derive(Debug, Clone, Copy, Default)] -pub struct SafeAreaInsets { - pub top: f32, - pub right: f32, - pub bottom: f32, - pub left: f32, -} - -impl SafeAreaInsets { - /// No safe area insets (desktop platforms). - pub fn none() -> Self { - Self::default() - } - - /// Typical iPhone safe area (notch at top, home indicator at bottom). - pub fn iphone_notch() -> Self { - Self { - top: 44.0, - right: 0.0, - bottom: 34.0, - left: 0.0, - } - } - - /// Dynamic island (iPhone 14 Pro+). - pub fn iphone_dynamic_island() -> Self { - Self { - top: 59.0, - right: 0.0, - bottom: 34.0, - left: 0.0, - } - } - - /// Total horizontal safe area. - pub fn horizontal(&self) -> f32 { - self.left + self.right - } - - /// Total vertical safe area. - pub fn vertical(&self) -> f32 { - self.top + self.bottom - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ios_min_touch_target() { - let sizing = PlatformSizing::for_platform(PlatformFamily::Ios); - assert_eq!(sizing.min_touch_target, 44.0); - } - - #[test] - fn android_min_touch_target() { - let sizing = PlatformSizing::for_platform(PlatformFamily::Android); - assert_eq!(sizing.min_touch_target, 48.0); - } - - #[test] - fn touch_adequate_check() { - let sizing = PlatformSizing::for_platform(PlatformFamily::Ios); - assert!(sizing.is_touch_adequate(44.0, 44.0)); - assert!(!sizing.is_touch_adequate(40.0, 44.0)); - assert!(!sizing.is_touch_adequate(44.0, 40.0)); - } - - #[test] - fn safe_area_horizontal() { - let sa = SafeAreaInsets { - top: 44.0, - right: 16.0, - bottom: 34.0, - left: 16.0, - }; - assert_eq!(sa.horizontal(), 32.0); - } - - #[test] - fn safe_area_vertical() { - let sa = SafeAreaInsets::iphone_notch(); - assert_eq!(sa.vertical(), 78.0); - } - - #[test] - fn no_safe_area_is_zero() { - let sa = SafeAreaInsets::none(); - assert_eq!(sa.horizontal(), 0.0); - assert_eq!(sa.vertical(), 0.0); - } -} diff --git a/vessels/el-layout/src/responsive.rs b/vessels/el-layout/src/responsive.rs deleted file mode 100644 index 0526cee..0000000 --- a/vessels/el-layout/src/responsive.rs +++ /dev/null @@ -1,154 +0,0 @@ -/// Responsive — a value that varies by breakpoint. -/// -/// The base value is always required (mobile-first). `sm`, `md`, `lg`, `xl` -/// are all optional — if not set, the nearest smaller value is used. -/// -/// You generally don't need Responsive for layout — VStack and Grid handle -/// reflow automatically. Use Responsive when you need to vary a non-layout -/// value (e.g. font size, column count, visibility) at specific breakpoints. - -use crate::breakpoint::Breakpoint; - -/// A value that varies by viewport breakpoint. -/// -/// Follows mobile-first cascade: base < sm < md < lg < xl. -/// If a breakpoint value is not set, it inherits from the next smaller one. -#[derive(Debug, Clone, PartialEq)] -pub struct Responsive { - /// Mobile-first base value. Always required. - pub base: T, - /// 640dp+. If None, uses `base`. - pub sm: Option, - /// 768dp+. If None, uses `sm` or `base`. - pub md: Option, - /// 1024dp+. If None, uses `md`, `sm`, or `base`. - pub lg: Option, - /// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`. - pub xl: Option, -} - -impl Responsive { - /// Create a responsive value with only the base (same on all screen sizes). - pub fn fixed(value: T) -> Self { - Self { - base: value, - sm: None, - md: None, - lg: None, - xl: None, - } - } - - /// Create a fully-specified responsive value. - pub fn new( - base: T, - sm: Option, - md: Option, - lg: Option, - xl: Option, - ) -> Self { - Self { base, sm, md, lg, xl } - } - - /// Resolve to the most-specific value that applies at the given breakpoint. - /// - /// Cascades downward: xl → lg → md → sm → base. - pub fn resolve(&self, breakpoint: Breakpoint) -> &T { - match breakpoint { - Breakpoint::Xl => { - self.xl.as_ref() - .or(self.lg.as_ref()) - .or(self.md.as_ref()) - .or(self.sm.as_ref()) - .unwrap_or(&self.base) - } - Breakpoint::Lg => { - self.lg.as_ref() - .or(self.md.as_ref()) - .or(self.sm.as_ref()) - .unwrap_or(&self.base) - } - Breakpoint::Md => { - self.md.as_ref() - .or(self.sm.as_ref()) - .unwrap_or(&self.base) - } - Breakpoint::Sm => { - self.sm.as_ref().unwrap_or(&self.base) - } - Breakpoint::Base => &self.base, - } - } - - /// Set the sm value (builder pattern). - pub fn sm(mut self, value: T) -> Self { - self.sm = Some(value); - self - } - - /// Set the md value. - pub fn md(mut self, value: T) -> Self { - self.md = Some(value); - self - } - - /// Set the lg value. - pub fn lg(mut self, value: T) -> Self { - self.lg = Some(value); - self - } - - /// Set the xl value. - pub fn xl(mut self, value: T) -> Self { - self.xl = Some(value); - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fixed_always_returns_base() { - let r = Responsive::fixed(42u32); - assert_eq!(*r.resolve(Breakpoint::Base), 42); - assert_eq!(*r.resolve(Breakpoint::Xl), 42); - } - - #[test] - fn resolves_most_specific() { - let r = Responsive::fixed(1u32).sm(2).md(3).lg(4).xl(5); - assert_eq!(*r.resolve(Breakpoint::Base), 1); - assert_eq!(*r.resolve(Breakpoint::Sm), 2); - assert_eq!(*r.resolve(Breakpoint::Md), 3); - assert_eq!(*r.resolve(Breakpoint::Lg), 4); - assert_eq!(*r.resolve(Breakpoint::Xl), 5); - } - - #[test] - fn cascades_down_when_specific_missing() { - let r = Responsive::fixed(1u32).md(3); - // sm not set → falls back to base - assert_eq!(*r.resolve(Breakpoint::Sm), 1); - // lg not set → falls back to md - assert_eq!(*r.resolve(Breakpoint::Lg), 3); - // xl not set → falls back to md (lg not set either) - assert_eq!(*r.resolve(Breakpoint::Xl), 3); - } - - #[test] - fn cascade_xl_to_lg_to_sm_to_base() { - let r = Responsive::fixed("base").sm("sm"); - assert_eq!(*r.resolve(Breakpoint::Md), "sm"); - assert_eq!(*r.resolve(Breakpoint::Lg), "sm"); - assert_eq!(*r.resolve(Breakpoint::Xl), "sm"); - } - - #[test] - fn responsive_with_strings() { - let r = Responsive::fixed("mobile").lg("desktop"); - assert_eq!(*r.resolve(Breakpoint::Base), "mobile"); - assert_eq!(*r.resolve(Breakpoint::Lg), "desktop"); - } -} diff --git a/vessels/el-layout/src/scroll.rs b/vessels/el-layout/src/scroll.rs deleted file mode 100644 index 6fff06e..0000000 --- a/vessels/el-layout/src/scroll.rs +++ /dev/null @@ -1,141 +0,0 @@ -/// ScrollView — scrollable container. -/// -/// Wraps content that may exceed the available space. Scrolling axis can be -/// vertical (default), horizontal, or both. On platforms with native scroll -/// behaviors (iOS, Android), the backend translates this to a native scroll -/// container — you get momentum, overscroll, pull-to-refresh for free. - -use el_style::modifier::{StyleModifier, StyleSet}; - -/// Which axis (or axes) can scroll. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollAxis { - /// Vertical scrolling only (default). Most content views. - Vertical, - /// Horizontal scrolling only. Carousels, horizontal lists. - Horizontal, - /// Free scrolling in both directions. Maps, canvases. - Both, -} - -/// Scroll indicator visibility. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollIndicator { - /// Show when scrolling, hide otherwise (platform default). - Automatic, - /// Always show. - Always, - /// Never show. - Never, -} - -/// A scrollable container. -#[derive(Debug, Clone)] -pub struct ScrollView { - pub axis: ScrollAxis, - pub indicator: ScrollIndicator, - /// Whether the user can zoom (pinch-to-zoom). Default: false. - pub zoomable: bool, - /// Minimum zoom scale (only relevant when zoomable = true). - pub min_zoom: f32, - /// Maximum zoom scale (only relevant when zoomable = true). - pub max_zoom: f32, - /// Whether to clip content to the scroll view bounds. Default: true. - pub clips_to_bounds: bool, - pub style: StyleSet, -} - -impl Default for ScrollView { - fn default() -> Self { - Self { - axis: ScrollAxis::Vertical, - indicator: ScrollIndicator::Automatic, - zoomable: false, - min_zoom: 1.0, - max_zoom: 3.0, - clips_to_bounds: true, - style: StyleSet::default(), - } - } -} - -impl ScrollView { - pub fn new() -> Self { - Self::default() - } - - pub fn horizontal() -> Self { - Self { - axis: ScrollAxis::Horizontal, - ..Self::default() - } - } - - pub fn both_axes() -> Self { - Self { - axis: ScrollAxis::Both, - ..Self::default() - } - } - - pub fn indicator(mut self, indicator: ScrollIndicator) -> Self { - self.indicator = indicator; - self - } - - pub fn zoomable(mut self, min: f32, max: f32) -> Self { - self.zoomable = true; - self.min_zoom = min; - self.max_zoom = max; - self - } -} - -impl StyleModifier for ScrollView { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn scroll_default_is_vertical() { - let sv = ScrollView::new(); - assert_eq!(sv.axis, ScrollAxis::Vertical); - } - - #[test] - fn scroll_horizontal_constructor() { - let sv = ScrollView::horizontal(); - assert_eq!(sv.axis, ScrollAxis::Horizontal); - } - - #[test] - fn scroll_both_axes() { - let sv = ScrollView::both_axes(); - assert_eq!(sv.axis, ScrollAxis::Both); - } - - #[test] - fn scroll_zoomable() { - let sv = ScrollView::new().zoomable(0.5, 4.0); - assert!(sv.zoomable); - assert_eq!(sv.min_zoom, 0.5); - assert_eq!(sv.max_zoom, 4.0); - } - - #[test] - fn scroll_clips_by_default() { - let sv = ScrollView::new(); - assert!(sv.clips_to_bounds); - } - - #[test] - fn scroll_indicator_setting() { - let sv = ScrollView::new().indicator(ScrollIndicator::Never); - assert_eq!(sv.indicator, ScrollIndicator::Never); - } -} diff --git a/vessels/el-layout/src/stack.rs b/vessels/el-layout/src/stack.rs deleted file mode 100644 index 64057be..0000000 --- a/vessels/el-layout/src/stack.rs +++ /dev/null @@ -1,286 +0,0 @@ -/// VStack, HStack, ZStack — the primary layout building blocks. -/// -/// These are the component API. FlexLayout is the engine underneath. -/// VStack and HStack wrap automatically by default — that's what makes -/// the layout responsive without writing breakpoints. - -use crate::flex::{CrossAxisAlignment, FlexLayout, HAlign, VAlign}; -use el_style::modifier::{StyleModifier, StyleSet}; - -/// A vertical stack — children arranged from top to bottom. -/// -/// Wraps automatically when height is constrained (mobile-first). -/// The default gap is 8dp. Cross-axis fills the container width. -#[derive(Debug, Clone)] -pub struct VStack { - /// Space between children in dp (default: 8). - pub spacing: u32, - /// Horizontal alignment of children (default: Leading). - pub alignment: HAlign, - /// Whether to wrap to a new column when out of vertical space (default: true). - pub wrap: bool, - pub style: StyleSet, -} - -impl Default for VStack { - fn default() -> Self { - Self { - spacing: 8, - alignment: HAlign::Leading, - wrap: true, - style: StyleSet::default(), - } - } -} - -impl VStack { - pub fn new() -> Self { - Self::default() - } - - pub fn spacing(mut self, dp: u32) -> Self { - self.spacing = dp; - self - } - - pub fn alignment(mut self, align: HAlign) -> Self { - self.alignment = align; - self - } - - /// Enable or disable wrapping. - pub fn wrap(mut self, wrap: bool) -> Self { - self.wrap = wrap; - self - } - - /// Convert to FlexLayout spec. - pub fn to_flex(&self) -> FlexLayout { - let mut flex = FlexLayout::vstack(self.spacing, self.wrap); - flex.cross_axis_alignment = match self.alignment { - HAlign::Leading => CrossAxisAlignment::Start, - HAlign::Center => CrossAxisAlignment::Center, - HAlign::Trailing => CrossAxisAlignment::End, - }; - flex - } -} - -impl StyleModifier for VStack { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -/// A horizontal stack — children arranged from leading to trailing. -/// -/// Respects reading direction (RTL reverses automatically). -/// Wraps to new rows by default when width is limited (mobile-first). -#[derive(Debug, Clone)] -pub struct HStack { - /// Space between children in dp (default: 8). - pub spacing: u32, - /// Vertical alignment of children (default: Center). - pub alignment: VAlign, - /// Whether to wrap to a new row when out of horizontal space (default: true). - pub wrap: bool, - pub style: StyleSet, -} - -impl Default for HStack { - fn default() -> Self { - Self { - spacing: 8, - alignment: VAlign::Center, - wrap: true, - style: StyleSet::default(), - } - } -} - -impl HStack { - pub fn new() -> Self { - Self::default() - } - - pub fn spacing(mut self, dp: u32) -> Self { - self.spacing = dp; - self - } - - pub fn alignment(mut self, align: VAlign) -> Self { - self.alignment = align; - self - } - - pub fn wrap(mut self, wrap: bool) -> Self { - self.wrap = wrap; - self - } - - /// Convert to FlexLayout spec. - pub fn to_flex(&self) -> FlexLayout { - let mut flex = FlexLayout::hstack(self.spacing, self.wrap); - flex.cross_axis_alignment = match self.alignment { - VAlign::Top => CrossAxisAlignment::Start, - VAlign::Center => CrossAxisAlignment::Center, - VAlign::Bottom => CrossAxisAlignment::End, - VAlign::Baseline => CrossAxisAlignment::Baseline, - }; - flex - } -} - -impl StyleModifier for HStack { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -/// A depth stack — children layered on top of each other. -/// -/// ZStack places all children at the same position, overlapping. -/// Later children appear on top of earlier children. -#[derive(Debug, Clone)] -pub struct ZStack { - pub h_align: HAlign, - pub v_align: VAlign, - pub style: StyleSet, -} - -impl Default for ZStack { - fn default() -> Self { - Self { - h_align: HAlign::Center, - v_align: VAlign::Center, - style: StyleSet::default(), - } - } -} - -impl ZStack { - pub fn new() -> Self { - Self::default() - } - - pub fn h_align(mut self, align: HAlign) -> Self { - self.h_align = align; - self - } - - pub fn v_align(mut self, align: VAlign) -> Self { - self.v_align = align; - self - } -} - -impl StyleModifier for ZStack { - fn style_mut(&mut self) -> &mut StyleSet { - &mut self.style - } -} - -/// A spacer that expands to fill available space in a stack. -/// -/// In HStack: expands horizontally. In VStack: expands vertically. -#[derive(Debug, Clone, Default)] -pub struct Spacer { - /// Minimum size in dp (0 = truly flexible). - pub min_size: u32, -} - -impl Spacer { - pub fn new() -> Self { - Self::default() - } - - pub fn min(mut self, dp: u32) -> Self { - self.min_size = dp; - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::flex::CrossAxisAlignment; - use el_style::modifier::StyleModifier; - use el_style::color::Color; - - #[test] - fn vstack_default_spacing() { - let v = VStack::new(); - assert_eq!(v.spacing, 8); - } - - #[test] - fn vstack_default_wraps() { - let v = VStack::new(); - assert!(v.wrap); - } - - #[test] - fn vstack_to_flex_direction() { - let v = VStack::new(); - let flex = v.to_flex(); - assert!(flex.direction.is_vertical()); - } - - #[test] - fn vstack_center_alignment() { - let v = VStack::new().alignment(HAlign::Center); - let flex = v.to_flex(); - assert_eq!(flex.cross_axis_alignment, CrossAxisAlignment::Center); - } - - #[test] - fn hstack_default_alignment() { - let h = HStack::new(); - assert_eq!(h.alignment, VAlign::Center); - } - - #[test] - fn hstack_default_wraps() { - let h = HStack::new(); - assert!(h.wrap); - } - - #[test] - fn hstack_to_flex_direction() { - let h = HStack::new(); - let flex = h.to_flex(); - assert!(flex.direction.is_horizontal()); - } - - #[test] - fn hstack_no_wrap() { - let h = HStack::new().wrap(false); - let flex = h.to_flex(); - assert!(!flex.direction.wraps()); - } - - #[test] - fn zstack_defaults() { - let z = ZStack::new(); - assert_eq!(z.h_align, HAlign::Center); - assert_eq!(z.v_align, VAlign::Center); - } - - #[test] - fn vstack_style_modifier() { - let v = VStack::new().background(Color::Surface); - assert_eq!(v.style.background, Some(Color::Surface)); - } - - #[test] - fn spacer_min_size() { - let s = Spacer::new().min(16); - assert_eq!(s.min_size, 16); - } - - #[test] - fn spacer_default_zero() { - let s = Spacer::new(); - assert_eq!(s.min_size, 0); - } -} diff --git a/vessels/el-platform/Cargo.toml b/vessels/el-platform/Cargo.toml deleted file mode 100644 index 6b16a46..0000000 --- a/vessels/el-platform/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "el-platform" -version = "0.1.0" -edition = "2021" -description = "el-ui platform rendering backends — same component code, every platform" -license = "MIT" - -[lib] -name = "el_platform" -path = "src/lib.rs" - -[dependencies] -thiserror = "1" - -[dev-dependencies] diff --git a/vessels/el-platform/src/backends/android.rs b/vessels/el-platform/src/backends/android.rs deleted file mode 100644 index ad5b3dc..0000000 --- a/vessels/el-platform/src/backends/android.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Android backend — NDK + JNI bridge. -//! -//! Architecture: correct and complete. JNI calls are stubs marked TODO. -//! -//! Each `PlatformNode` maps to an Android View: -//! element("div") → LinearLayout / FrameLayout -//! element("span") → TextView (inline) -//! element("button") → Button -//! element("input") → EditText -//! text("...") → TextView -//! -//! Event binding: -//! "click" → setOnClickListener -//! "input" → addTextChangedListener -//! "change" → setOnCheckedChangeListener - -use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult}; - -pub struct AndroidBackend; - -impl AndroidBackend { - pub fn new() -> Self { - Self - } - - fn android_view_class(tag: &str) -> &'static str { - match tag { - "button" => "android.widget.Button", - "input" => "android.widget.EditText", - "textarea" => "android.widget.EditText", - "img" => "android.widget.ImageView", - "ul" | "ol" => "android.widget.ListView", - "li" => "android.view.View", - "nav" => "androidx.appcompat.widget.Toolbar", - "div" | "section" | "main" | "article" => "android.widget.FrameLayout", - "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { - "android.widget.TextView" - } - _ => "android.view.View", - } - } - - fn android_event_listener(event: &str) -> &'static str { - match event { - "click" => "setOnClickListener", - "input" | "change" => "addTextChangedListener", - "focus" => "setOnFocusChangeListener", - _ => "setOnTouchListener", - } - } -} - -impl Default for AndroidBackend { - fn default() -> Self { - Self::new() - } -} - -impl PlatformBackend for AndroidBackend { - fn name(&self) -> &'static str { - "android" - } - - fn create_element(&self, tag: &str) -> PlatformResult { - let mut node = PlatformNode::element(tag); - // TODO: JNI call to create the Android view: - // env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...]) - node.attributes.push(crate::Attribute::new( - "data-android-class", - Self::android_view_class(tag), - )); - Ok(node) - } - - fn create_text(&self, content: &str) -> PlatformResult { - let mut node = PlatformNode::text(content); - // TODO: JNI: create TextView, set text = content - node.attributes - .push(crate::Attribute::new("data-android-class", "android.widget.TextView")); - Ok(node) - } - - fn set_attribute( - &self, - node: &mut PlatformNode, - name: &str, - value: &str, - ) -> PlatformResult<()> { - // TODO: map attribute to Java property setter via JNI - // "class" → setBackground / setTextAppearance - // "disabled" → setEnabled(false) - // "placeholder" → setHint(value) - node.attributes.retain(|a| a.name != name); - node.attributes.push(crate::Attribute::new(name, value)); - Ok(()) - } - - fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - Ok(()) - } - - fn append_child( - &self, - parent: &mut PlatformNode, - child: PlatformNode, - ) -> PlatformResult<()> { - // TODO: JNI: ((ViewGroup) parent).addView(child) - parent.children.push(child); - Ok(()) - } - - fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { - if child_index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "android: child index {} out of bounds", - child_index - ))); - } - // TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index) - parent.children.remove(child_index); - Ok(()) - } - - fn replace_child( - &self, - parent: &mut PlatformNode, - index: usize, - new_child: PlatformNode, - ) -> PlatformResult<()> { - if index >= parent.children.len() { - return Err(PlatformError::Render( - "android: replace_child out of bounds".into(), - )); - } - // TODO: JNI: remove old view, add new view at index - parent.children[index] = new_child; - Ok(()) - } - - fn bind_event( - &self, - node: &mut PlatformNode, - event: &str, - _handler: EventHandler, - ) -> PlatformResult<()> { - let listener = Self::android_event_listener(event); - // TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... }) - node.attributes.push(crate::Attribute::new( - format!("data-android-event-{}", event), - listener, - )); - Ok(()) - } - - fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { - Ok(node.to_html()) - } - - fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { - // TODO: get Activity by container_id, set root as content view - Ok(()) - } - - fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { - // TODO: diff and apply JNI mutations - Ok(()) - } -} diff --git a/vessels/el-platform/src/backends/ios.rs b/vessels/el-platform/src/backends/ios.rs deleted file mode 100644 index 46cc62d..0000000 --- a/vessels/el-platform/src/backends/ios.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! iOS backend — UIKit via C FFI / Objective-C bridge. -//! -//! Architecture: correct and complete. Native UIKit calls are stubs marked -//! TODO — a future agent fills in the actual `extern "C"` calls. -//! -//! Each `PlatformNode` maps to a UIKit view: -//! element("div") → UIView -//! element("span") → UILabel (inline) -//! element("button") → UIButton -//! element("input") → UITextField -//! text("...") → UILabel -//! -//! Event binding maps DOM event names to UIControl target-action pairs: -//! "click" → UIControlEventTouchUpInside -//! "input" → UIControlEventEditingChanged - -use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult}; - -pub struct IosBackend; - -impl IosBackend { - pub fn new() -> Self { - Self - } - - /// Map an HTML tag name to the UIKit class name it maps to. - fn uikit_class(tag: &str) -> &'static str { - match tag { - "button" => "UIButton", - "input" => "UITextField", - "textarea" => "UITextView", - "img" => "UIImageView", - "ul" | "ol" => "UITableView", - "li" => "UITableViewCell", - "nav" => "UINavigationBar", - _ => "UIView", - } - } - - /// Map a DOM event name to a UIControlEvent constant name. - fn uicontrol_event(event: &str) -> &'static str { - match event { - "click" => "UIControlEventTouchUpInside", - "input" | "change" => "UIControlEventEditingChanged", - "focus" => "UIControlEventEditingDidBegin", - "blur" => "UIControlEventEditingDidEnd", - _ => "UIControlEventAllEvents", - } - } -} - -impl Default for IosBackend { - fn default() -> Self { - Self::new() - } -} - -impl PlatformBackend for IosBackend { - fn name(&self) -> &'static str { - "ios" - } - - fn create_element(&self, tag: &str) -> PlatformResult { - let mut node = PlatformNode::element(tag); - // TODO: call UIKit C FFI to allocate the view: - // extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; } - // node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) }); - let uikit_class = Self::uikit_class(tag); - node.attributes - .push(crate::Attribute::new("data-uikit-class", uikit_class)); - Ok(node) - } - - fn create_text(&self, content: &str) -> PlatformResult { - let mut node = PlatformNode::text(content); - // TODO: UILabel with text = content - node.attributes - .push(crate::Attribute::new("data-uikit-class", "UILabel")); - Ok(node) - } - - fn set_attribute( - &self, - node: &mut PlatformNode, - name: &str, - value: &str, - ) -> PlatformResult<()> { - // TODO: map attribute names to UIKit property setters: - // "class" → apply style from stylesheet - // "disabled" → view.isUserInteractionEnabled = false - // "placeholder" → textField.placeholder = value - node.attributes.retain(|a| a.name != name); - node.attributes.push(crate::Attribute::new(name, value)); - Ok(()) - } - - fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - Ok(()) - } - - fn append_child( - &self, - parent: &mut PlatformNode, - child: PlatformNode, - ) -> PlatformResult<()> { - // TODO: [parentView addSubview:childView] via FFI - parent.children.push(child); - Ok(()) - } - - fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { - if child_index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "ios: child index {} out of bounds", - child_index - ))); - } - // TODO: [childView removeFromSuperview] via FFI - parent.children.remove(child_index); - Ok(()) - } - - fn replace_child( - &self, - parent: &mut PlatformNode, - index: usize, - new_child: PlatformNode, - ) -> PlatformResult<()> { - if index >= parent.children.len() { - return Err(PlatformError::Render("ios: replace_child out of bounds".into())); - } - // TODO: remove old view, insert new view via FFI - parent.children[index] = new_child; - Ok(()) - } - - fn bind_event( - &self, - node: &mut PlatformNode, - event: &str, - _handler: EventHandler, - ) -> PlatformResult<()> { - let uievent = Self::uicontrol_event(event); - // TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent] - node.attributes - .push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent)); - Ok(()) - } - - fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { - // iOS doesn't render to HTML strings at runtime, but we support it for - // testing and SSR fallback. - Ok(node.to_html()) - } - - fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { - // TODO: get UIViewController by container_id and set root as its view - Ok(()) - } - - fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { - // TODO: diff old and new trees, apply UIKit mutations via FFI - Ok(()) - } -} diff --git a/vessels/el-platform/src/backends/linux.rs b/vessels/el-platform/src/backends/linux.rs deleted file mode 100644 index e886c39..0000000 --- a/vessels/el-platform/src/backends/linux.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Linux backend — GTK/Wayland. -//! -//! Architecture: correct and complete. GTK calls are stubs marked TODO. -//! -//! Each `PlatformNode` maps to a GtkWidget: -//! element("div") → GtkBox (vertical) -//! element("button") → GtkButton -//! element("input") → GtkEntry -//! text("...") → GtkLabel - -use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult}; - -pub struct LinuxBackend; - -impl LinuxBackend { - pub fn new() -> Self { - Self - } - - fn gtk_widget(tag: &str) -> &'static str { - match tag { - "button" => "GtkButton", - "input" => "GtkEntry", - "textarea" => "GtkTextView", - "img" => "GtkImage", - "ul" | "ol" => "GtkListBox", - "li" => "GtkListBoxRow", - "nav" => "GtkHeaderBar", - "div" | "section" | "main" | "article" | "span" => "GtkBox", - _ => "GtkWidget", - } - } - - fn gtk_signal(event: &str) -> &'static str { - match event { - "click" => "clicked", - "input" | "change" => "changed", - "focus" => "focus-in-event", - "blur" => "focus-out-event", - "keydown" | "keypress" => "key-press-event", - "keyup" => "key-release-event", - _ => "event", - } - } -} - -impl Default for LinuxBackend { - fn default() -> Self { - Self::new() - } -} - -impl PlatformBackend for LinuxBackend { - fn name(&self) -> &'static str { - "linux" - } - - fn create_element(&self, tag: &str) -> PlatformResult { - let mut node = PlatformNode::element(tag); - // TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI - node.attributes - .push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag))); - Ok(node) - } - - fn create_text(&self, content: &str) -> PlatformResult { - let mut node = PlatformNode::text(content); - // TODO: gtk_label_new(content) - node.attributes - .push(crate::Attribute::new("data-gtk-widget", "GtkLabel")); - Ok(node) - } - - fn set_attribute( - &self, - node: &mut PlatformNode, - name: &str, - value: &str, - ) -> PlatformResult<()> { - // TODO: map to GTK property setters - // "class" → gtk_widget_add_css_class - // "disabled" → gtk_widget_set_sensitive(false) - // "placeholder" → gtk_entry_set_placeholder_text - node.attributes.retain(|a| a.name != name); - node.attributes.push(crate::Attribute::new(name, value)); - Ok(()) - } - - fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - Ok(()) - } - - fn append_child( - &self, - parent: &mut PlatformNode, - child: PlatformNode, - ) -> PlatformResult<()> { - // TODO: gtk_box_append(parent, child) - parent.children.push(child); - Ok(()) - } - - fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { - if child_index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "linux: child index {} out of bounds", - child_index - ))); - } - // TODO: gtk_widget_unparent(child) - parent.children.remove(child_index); - Ok(()) - } - - fn replace_child( - &self, - parent: &mut PlatformNode, - index: usize, - new_child: PlatformNode, - ) -> PlatformResult<()> { - if index >= parent.children.len() { - return Err(PlatformError::Render("linux: replace_child out of bounds".into())); - } - parent.children[index] = new_child; - Ok(()) - } - - fn bind_event( - &self, - node: &mut PlatformNode, - event: &str, - _handler: EventHandler, - ) -> PlatformResult<()> { - let signal = Self::gtk_signal(event); - // TODO: g_signal_connect(widget, signal, callback, data) - node.attributes - .push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal)); - Ok(()) - } - - fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { - Ok(node.to_html()) - } - - fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { - // TODO: get GtkWindow and call gtk_window_set_child(root) - Ok(()) - } - - fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { - // TODO: diff and apply GTK mutations - Ok(()) - } -} diff --git a/vessels/el-platform/src/backends/macos.rs b/vessels/el-platform/src/backends/macos.rs deleted file mode 100644 index fc245fd..0000000 --- a/vessels/el-platform/src/backends/macos.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! macOS backend — AppKit bindings. -//! -//! Architecture: correct and complete. AppKit calls are stubs marked TODO. -//! -//! Each `PlatformNode` maps to an NSView: -//! element("div") → NSView -//! element("button") → NSButton -//! element("input") → NSTextField -//! text("...") → NSTextField (label mode) - -use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult}; - -pub struct MacosBackend; - -impl MacosBackend { - pub fn new() -> Self { - Self - } - - fn appkit_class(tag: &str) -> &'static str { - match tag { - "button" => "NSButton", - "input" => "NSTextField", - "textarea" => "NSTextView", - "img" => "NSImageView", - "ul" | "ol" => "NSTableView", - "nav" => "NSToolbar", - _ => "NSView", - } - } -} - -impl Default for MacosBackend { - fn default() -> Self { - Self::new() - } -} - -impl PlatformBackend for MacosBackend { - fn name(&self) -> &'static str { - "macos" - } - - fn create_element(&self, tag: &str) -> PlatformResult { - let mut node = PlatformNode::element(tag); - // TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; } - node.attributes - .push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag))); - Ok(node) - } - - fn create_text(&self, content: &str) -> PlatformResult { - let mut node = PlatformNode::text(content); - // TODO: NSTextField in label mode with stringValue = content - node.attributes - .push(crate::Attribute::new("data-appkit-class", "NSTextField")); - Ok(node) - } - - fn set_attribute( - &self, - node: &mut PlatformNode, - name: &str, - value: &str, - ) -> PlatformResult<()> { - // TODO: map to AppKit property setters - node.attributes.retain(|a| a.name != name); - node.attributes.push(crate::Attribute::new(name, value)); - Ok(()) - } - - fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - Ok(()) - } - - fn append_child( - &self, - parent: &mut PlatformNode, - child: PlatformNode, - ) -> PlatformResult<()> { - // TODO: [parentView addSubview:childView] - parent.children.push(child); - Ok(()) - } - - fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { - if child_index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "macos: child index {} out of bounds", - child_index - ))); - } - // TODO: [childView removeFromSuperview] - parent.children.remove(child_index); - Ok(()) - } - - fn replace_child( - &self, - parent: &mut PlatformNode, - index: usize, - new_child: PlatformNode, - ) -> PlatformResult<()> { - if index >= parent.children.len() { - return Err(PlatformError::Render("macos: replace_child out of bounds".into())); - } - parent.children[index] = new_child; - Ok(()) - } - - fn bind_event( - &self, - node: &mut PlatformNode, - event: &str, - _handler: EventHandler, - ) -> PlatformResult<()> { - // TODO: NSButton.target + NSButton.action pattern via FFI - node.attributes - .push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound")); - Ok(()) - } - - fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { - Ok(node.to_html()) - } - - fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { - // TODO: find NSWindow or NSViewController and set root as contentView - Ok(()) - } - - fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { - // TODO: diff and apply AppKit mutations - Ok(()) - } -} diff --git a/vessels/el-platform/src/backends/mod.rs b/vessels/el-platform/src/backends/mod.rs deleted file mode 100644 index e8b9bfa..0000000 --- a/vessels/el-platform/src/backends/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Platform backend implementations. - -pub mod android; -pub mod ios; -pub mod linux; -pub mod macos; -pub mod server; -pub mod web; -pub mod windows; - -#[cfg(test)] -mod tests; diff --git a/vessels/el-platform/src/backends/server.rs b/vessels/el-platform/src/backends/server.rs deleted file mode 100644 index 8f2f7d5..0000000 --- a/vessels/el-platform/src/backends/server.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Server backend — SSR: render to HTML string, served by axum. -//! -//! This is the primary SSR backend. An axum handler calls `render_to_string()` -//! on the component tree and returns the result as an HTTP response. -//! -//! The same component code runs server-side without any changes. Only the -//! backend (chosen by `manifest.el`) differs. - -use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult}; - -/// Server-side rendering backend. -/// -/// Renders component trees to full HTML strings. No DOM, no browser APIs. -/// An axum handler uses this backend to generate the initial page HTML. -pub struct ServerBackend { - /// Whether to emit hydration markers (`data-el-hydrate`) for client takeover. - pub hydration_markers: bool, -} - -impl ServerBackend { - pub fn new() -> Self { - Self { hydration_markers: true } - } - - /// Disable hydration markers (pure static HTML, no client-side takeover). - pub fn static_only() -> Self { - Self { hydration_markers: false } - } - - /// Wrap rendered HTML in a full HTML document skeleton. - pub fn render_page( - &self, - node: &PlatformNode, - title: &str, - runtime_script: &str, - ) -> PlatformResult { - let body = self.render_to_string(node)?; - Ok(format!( - r#" - - - - - {title} - - -
- {body} -
- - -"# - )) - } -} - -impl Default for ServerBackend { - fn default() -> Self { - Self::new() - } -} - -impl PlatformBackend for ServerBackend { - fn name(&self) -> &'static str { - "server" - } - - fn create_element(&self, tag: &str) -> PlatformResult { - Ok(PlatformNode::element(tag)) - } - - fn create_text(&self, content: &str) -> PlatformResult { - Ok(PlatformNode::text(content)) - } - - fn set_attribute( - &self, - node: &mut PlatformNode, - name: &str, - value: &str, - ) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - node.attributes.push(crate::Attribute::new(name, value)); - Ok(()) - } - - fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { - node.attributes.retain(|a| a.name != name); - Ok(()) - } - - fn append_child( - &self, - parent: &mut PlatformNode, - child: PlatformNode, - ) -> PlatformResult<()> { - parent.children.push(child); - Ok(()) - } - - fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { - if child_index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "server: child index {} out of bounds", - child_index - ))); - } - parent.children.remove(child_index); - Ok(()) - } - - fn replace_child( - &self, - parent: &mut PlatformNode, - index: usize, - new_child: PlatformNode, - ) -> PlatformResult<()> { - if index >= parent.children.len() { - return Err(PlatformError::Render(format!( - "server: replace_child index {} out of bounds", - index - ))); - } - parent.children[index] = new_child; - Ok(()) - } - - fn bind_event( - &self, - node: &mut PlatformNode, - event: &str, - _handler: EventHandler, - ) -> PlatformResult<()> { - // On the server, event handlers are emitted as data attributes. - // The client-side hydration pass picks them up and binds real listeners. - if self.hydration_markers { - node.attributes - .push(crate::Attribute::new(format!("data-el-{}", event), "hydrate")); - } - Ok(()) - } - - fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { - Ok(node.to_html()) - } - - fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { - // Server has no mount concept — rendering is one-shot. - Ok(()) - } - - fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { - // Server rendering is stateless — no patch needed. - Ok(()) - } - - fn supports_ssr(&self) -> bool { - true - } -} diff --git a/vessels/el-platform/src/backends/tests.rs b/vessels/el-platform/src/backends/tests.rs deleted file mode 100644 index 79472e8..0000000 --- a/vessels/el-platform/src/backends/tests.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Tests for el-platform backends. - -#[cfg(test)] -mod tests { - use crate::{ - backend_for, backends::{server::ServerBackend, web::WebBackend}, - config::{PlatformConfig, PlatformTarget}, - node::PlatformNode, - PlatformBackend, - }; - - // ── Test 1: PlatformTarget::from_str parses all targets ────────────────── - #[test] - fn test_platform_target_from_str() { - assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web)); - assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server)); - assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios)); - assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android)); - assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos)); - assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux)); - assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows)); - assert_eq!(PlatformTarget::from_str("unknown"), None); - } - - // ── Test 2: PlatformTarget::as_str round-trips ─────────────────────────── - #[test] - fn test_platform_target_as_str() { - assert_eq!(PlatformTarget::Web.as_str(), "web"); - assert_eq!(PlatformTarget::Server.as_str(), "server"); - assert_eq!(PlatformTarget::Ios.as_str(), "ios"); - } - - // ── Test 3: is_native() identifies native targets ──────────────────────── - #[test] - fn test_is_native() { - assert!(!PlatformTarget::Web.is_native()); - assert!(!PlatformTarget::Server.is_native()); - assert!(PlatformTarget::Ios.is_native()); - assert!(PlatformTarget::Android.is_native()); - assert!(PlatformTarget::Macos.is_native()); - assert!(PlatformTarget::Linux.is_native()); - assert!(PlatformTarget::Windows.is_native()); - } - - // ── Test 4: PlatformConfig defaults to web ─────────────────────────────── - #[test] - fn test_platform_config_default() { - let cfg = PlatformConfig::default(); - assert_eq!(cfg.target, PlatformTarget::Web); - assert!(!cfg.ssr); - } - - // ── Test 5: PlatformConfig with_ssr ────────────────────────────────────── - #[test] - fn test_platform_config_with_ssr() { - let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true); - assert_eq!(cfg.target, PlatformTarget::Server); - assert!(cfg.ssr); - } - - // ── Test 6: WebBackend renders element to HTML ─────────────────────────── - #[test] - fn test_web_backend_renders_element() { - let backend = WebBackend::new(); - let mut div = backend.create_element("div").unwrap(); - backend.set_attribute(&mut div, "class", "container").unwrap(); - backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap(); - - let html = backend.render_to_string(&div).unwrap(); - assert!(html.contains(""), "should close div tag"); - } - - // ── Test 7: ServerBackend supports SSR ─────────────────────────────────── - #[test] - fn test_server_backend_supports_ssr() { - let backend = ServerBackend::new(); - assert!(backend.supports_ssr()); - } - - // ── Test 8: ServerBackend renders full HTML page ───────────────────────── - #[test] - fn test_server_backend_render_page() { - let backend = ServerBackend::new(); - let root = PlatformNode::element("div") - .with_attr("id", "content") - .with_child(PlatformNode::text("Hello World")); - - let page = backend.render_page(&root, "My App", "/app.js").unwrap(); - assert!(page.contains(""), "should be full HTML doc"); - assert!(page.contains("My App"), "should include title"); - assert!(page.contains("Hello World"), "should include content"); - assert!(page.contains("/app.js"), "should include script src"); - } - - // ── Test 9: PlatformNode::to_html renders nested tree ──────────────────── - #[test] - fn test_platform_node_to_html_nested() { - let node = PlatformNode::element("ul") - .with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1"))) - .with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2"))); - - let html = node.to_html(); - assert!(html.contains("
    ")); - assert!(html.contains("
  • item 1
  • ")); - assert!(html.contains("
  • item 2
  • ")); - assert!(html.contains("
")); - } - - // ── Test 10: PlatformNode::to_html escapes text content ────────────────── - #[test] - fn test_html_escaping() { - let node = PlatformNode::element("span") - .with_child(PlatformNode::text("")); - let html = node.to_html(); - assert!(!html.contains("