8 Commits

Author SHA1 Message Date
Will Anderson 7e36983dd2 enforce source branch in CI: stage←dev, main←stage 2026-05-04 19:34:37 -05:00
Will Anderson 5fcb895dda Add release workflow with GCP prod publishing and ui-updated dispatch
Triggers on push to main and repository_dispatch el-sdk-updated.
Installs El SDK from foundation-prod Artifact Registry (Gitea fallback),
builds el-platform vessel, publishes to Gitea latest release and
foundation-prod Artifact Registry, then dispatches ui-updated to el-ide.
2026-05-04 19:32:14 -05:00
Will Anderson 0fddb7b5be update spec: mark ServerBackend SSR implemented, document hydration
Bumps spec version to 1.2.0.

Documents:
- SSR engine in runtime/src/ssr.js (Node.js, fully implemented)
- Hydration markers (data-el-component, data-el-id, data-el-state)
- hydrate() vs mount() and when to use each
- Full SSR + hydration lifecycle with code examples
- elc-ui CLI --target=server and --target=web flags
- Section 11: complete SSR + hydration walkthrough
- Section 12: versioning roadmap updated to reflect v0.1 actual state
- Section 9: clarifies that SSR is JS-implemented, Rust PlatformBackend is planned
2026-05-04 12:50:55 -05:00
Will Anderson 143137e2b4 add ssr-counter example demonstrating SSR + hydration end-to-end
Counter.el defines Counter, CounterPair, and App components with {#if}
conditionals, event handlers, and child component composition.

render.js (generated by elc-ui --target=server) is the Node.js render
script the server calls. app.js (generated by elc-ui --target=web) is the
compiled browser module. hydrate.js attaches the runtime to the
server-rendered DOM without replacing it.

index.html shows the integration point with instructions for injecting
server output and the two CLI commands needed to regenerate both targets.
2026-05-04 12:49:19 -05:00
Will Anderson f8e32e7719 add elc-ui CLI with --target=web and --target=server
elc-ui is a Node.js compiler CLI that reads .el component files and emits
either a browser ES2022 module (web target) or a Node.js CJS render script
(server target).

The server target embeds the component source at compile time and exports
renderToString(componentName, props) -> HTML string, which any El web server
can require() and call. CLI mode (node render.js ComponentName propsJson)
lets the web server invoke it via exec() as a subprocess.

The web target emits class-per-component JS matching the hand-compiled
format, so compiler output and hand-written components are interchangeable.
2026-05-04 12:47:34 -05:00
Will Anderson d24bb29817 implement el-ui SSR engine with renderToString and hydration markers
Adds runtime/src/ssr.js: a Node.js SSR engine that parses .el component
source and renders to HTML strings without a DOM. Covers the full template
AST -- elements, text, interpolations, {#if}, {#each}, {#activate}, and
child component recursion.

Every component root element receives hydration markers:
  data-el-component, data-el-id, data-el-state

Event bindings (on:click etc.) are preserved as data-el-* attributes so
the client hydration pass can bind them without re-rendering.

Updates Component base class with:
  - initialState constructor param for SSR state recovery
  - _hydrate(root): binds events on existing DOM, no re-render
  - onHydrate() lifecycle hook

Updates dist/el-ui.js bundle to match.
2026-05-04 12:45:27 -05:00
Will Anderson a9adea3b96 add gitflow CI for dev/stage/prod environments 2026-05-04 08:55:32 -05:00
Will Anderson 9f0da76ca2 remove rust scaffolding — el is the implementation 2026-05-03 04:05:04 -05:00
127 changed files with 2760 additions and 20259 deletions
+112
View File
@@ -0,0 +1,112 @@
name: El UI CI — dev
on:
push:
branches:
- dev
pull_request:
branches:
- dev
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install build dependencies
run: |
apt-get update -qq
apt-get install -y gcc libcurl4-openssl-dev
- name: Install El SDK from dev registry
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates gnupg curl
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
mkdir -p /usr/local/bin /usr/local/lib/el
LATEST_VERSION=$(gcloud artifacts versions list \
--repository=foundation-dev \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--format="value(name)" 2>/dev/null | sort | tail -1 | sed 's|.*/||' || true)
if [ -n "${LATEST_VERSION}" ]; then
gcloud artifacts generic download \
--repository=foundation-dev \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--version="${LATEST_VERSION}" \
--destination=/usr/local/bin/elc
chmod +x /usr/local/bin/elc
else
echo "Falling back to Gitea latest release..."
curl -fsSL "https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest/elc" \
-o /usr/local/bin/elc
chmod +x /usr/local/bin/elc
fi
RELEASE_BASE="https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest"
curl -fsSL "${RELEASE_BASE}/el_runtime.c" -o /usr/local/lib/el/el_runtime.c
curl -fsSL "${RELEASE_BASE}/el_runtime.h" -o /usr/local/lib/el/el_runtime.h
echo "El SDK installed:"; elc --version || true
rm -f /tmp/gcp-key.json
# Build el-platform vessel (foundation layer for all el-ui vessels)
- name: Build el-platform vessel
run: |
mkdir -p dist
elc vessels/el-platform/src/main.el > dist/el-platform.c
cc -std=c11 -O2 \
-I /usr/local/lib/el \
-o dist/el-platform \
dist/el-platform.c \
/usr/local/lib/el/el_runtime.c \
-lcurl -lpthread
chmod +x dist/el-platform
echo "Built dist/el-platform"
ls -lh dist/el-platform
# Run spec tests if present
- name: Run tests
run: |
if [ -f spec/run.sh ]; then
ELC=/usr/local/bin/elc bash spec/run.sh
elif [ -f tests/run.sh ]; then
ELC=/usr/local/bin/elc bash tests/run.sh
else
echo "No spec/run.sh or tests/run.sh — skipping"
fi
- name: Publish el-platform to Artifact Registry (dev)
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
VERSION="${GITEA_SHA:0:8}"
gcloud artifacts generic upload \
--repository=foundation-dev \
--location=us-central1 \
--project=neuron-785695 \
--package=el-ui/el-platform \
--version="${VERSION}" \
--source=dist/el-platform
echo "Published el-platform version=${VERSION} to foundation-dev/el-ui/el-platform"
rm -f /tmp/gcp-key.json
+120
View File
@@ -0,0 +1,120 @@
name: El UI CI — stage
on:
push:
branches:
- stage
pull_request:
branches:
- stage
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce source branch (stage ← dev only)
if: github.event_name == 'pull_request'
run: |
SOURCE="${GITHUB_HEAD_REF}"
if [ "${SOURCE}" != "dev" ]; then
echo "ERROR: Stage branch only accepts PRs from 'dev'. Source was: '${SOURCE}'"
exit 1
fi
echo "Source branch check passed: ${SOURCE} → stage"
- name: Install build dependencies
run: |
apt-get update -qq
apt-get install -y gcc libcurl4-openssl-dev
- name: Install El SDK from stage registry
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates gnupg curl
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
mkdir -p /usr/local/bin /usr/local/lib/el
LATEST_VERSION=$(gcloud artifacts versions list \
--repository=foundation-stage \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--format="value(name)" 2>/dev/null | sort | tail -1 | sed 's|.*/||' || true)
if [ -n "${LATEST_VERSION}" ]; then
gcloud artifacts generic download \
--repository=foundation-stage \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--version="${LATEST_VERSION}" \
--destination=/usr/local/bin/elc
chmod +x /usr/local/bin/elc
else
echo "Falling back to Gitea latest release..."
curl -fsSL "https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest/elc" \
-o /usr/local/bin/elc
chmod +x /usr/local/bin/elc
fi
RELEASE_BASE="https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest"
curl -fsSL "${RELEASE_BASE}/el_runtime.c" -o /usr/local/lib/el/el_runtime.c
curl -fsSL "${RELEASE_BASE}/el_runtime.h" -o /usr/local/lib/el/el_runtime.h
echo "El SDK installed:"; elc --version || true
rm -f /tmp/gcp-key.json
- name: Build el-platform vessel
run: |
mkdir -p dist
elc vessels/el-platform/src/main.el > dist/el-platform.c
cc -std=c11 -O2 \
-I /usr/local/lib/el \
-o dist/el-platform \
dist/el-platform.c \
/usr/local/lib/el/el_runtime.c \
-lcurl -lpthread
chmod +x dist/el-platform
echo "Built dist/el-platform"
ls -lh dist/el-platform
- name: Run tests
run: |
if [ -f spec/run.sh ]; then
ELC=/usr/local/bin/elc bash spec/run.sh
elif [ -f tests/run.sh ]; then
ELC=/usr/local/bin/elc bash tests/run.sh
else
echo "No spec/run.sh or tests/run.sh — skipping"
fi
- name: Publish el-platform to Artifact Registry (stage)
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
VERSION="${GITEA_SHA:0:8}"
gcloud artifacts generic upload \
--repository=foundation-stage \
--location=us-central1 \
--project=neuron-785695 \
--package=el-ui/el-platform \
--version="${VERSION}" \
--source=dist/el-platform
echo "Published el-platform version=${VERSION} to foundation-stage/el-ui/el-platform"
rm -f /tmp/gcp-key.json
+179
View File
@@ -0,0 +1,179 @@
name: El UI Release
on:
push:
branches:
- main
repository_dispatch:
types:
- el-sdk-updated
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce source branch (main ← stage only)
if: github.event_name == 'pull_request'
run: |
SOURCE="${GITHUB_HEAD_REF}"
if [ "${SOURCE}" != "stage" ]; then
echo "ERROR: Main branch only accepts PRs from 'stage'. Source was: '${SOURCE}'"
exit 1
fi
echo "Source branch check passed: ${SOURCE} → main"
- name: Install build dependencies
run: |
apt-get update -qq
apt-get install -y gcc libcurl4-openssl-dev
# Install El SDK — try GCP Artifact Registry prod first, fall back to Gitea release
- name: Install El SDK
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
RELEASE_BASE: https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates gnupg curl
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
mkdir -p /usr/local/bin /usr/local/lib/el
LATEST_VERSION=$(gcloud artifacts versions list \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--format="value(name)" 2>/dev/null | sort | tail -1 | sed 's|.*/||' || true)
if [ -n "${LATEST_VERSION}" ]; then
gcloud artifacts generic download \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--version="${LATEST_VERSION}" \
--destination=/usr/local/bin/elc
chmod +x /usr/local/bin/elc
else
echo "Falling back to Gitea latest release..."
curl -fsSL "${RELEASE_BASE}/elc" -o /usr/local/bin/elc
chmod +x /usr/local/bin/elc
fi
curl -fsSL "${RELEASE_BASE}/el_runtime.c" -o /usr/local/lib/el/el_runtime.c
curl -fsSL "${RELEASE_BASE}/el_runtime.h" -o /usr/local/lib/el/el_runtime.h
echo "El SDK installed:"; elc --version || true
rm -f /tmp/gcp-key.json
# Build el-platform vessel (foundation layer for all el-ui vessels)
- name: Build el-platform vessel
run: |
mkdir -p dist
elc vessels/el-platform/src/main.el > dist/el-platform.c
cc -std=c11 -O2 \
-I /usr/local/lib/el \
-o dist/el-platform \
dist/el-platform.c \
/usr/local/lib/el/el_runtime.c \
-lcurl -lpthread
chmod +x dist/el-platform
echo "Built dist/el-platform"
ls -lh dist/el-platform
- name: Run tests
run: |
if [ -f spec/run.sh ]; then
ELC=/usr/local/bin/elc bash spec/run.sh
elif [ -f tests/run.sh ]; then
ELC=/usr/local/bin/elc bash tests/run.sh
else
echo "No spec/run.sh or tests/run.sh — skipping"
fi
# Publish / update the `latest` release
- name: Publish latest release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.neuralplatform.ai/api/v1
REPO: neuron-technologies/el-ui
run: |
EXISTING_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/tags/latest" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing release id=${EXISTING_ID}"
curl -sf -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/${EXISTING_ID}"
fi
curl -sf -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/tags/latest" || true
RELEASE_ID=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_API}/repos/${REPO}/releases" \
-d "{
\"tag_name\": \"latest\",
\"name\": \"El UI (latest)\",
\"body\": \"Latest El UI build from commit ${GITHUB_SHA}.\nBuilt $(date -u +%Y-%m-%dT%H:%M:%SZ).\",
\"draft\": false,
\"prerelease\": false
}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created release id=${RELEASE_ID}"
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/el-platform;filename=el-platform" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets"
echo "Release published successfully"
# Publish artifact to GCP Artifact Registry (prod)
- name: Publish el-platform to Artifact Registry (prod)
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
VERSION="${GITEA_SHA:0:8}"
gcloud artifacts generic upload \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el-ui/el-platform \
--version="${VERSION}" \
--source=dist/el-platform
echo "Published el-platform version=${VERSION} to foundation-prod/el-ui/el-platform"
rm -f /tmp/gcp-key.json
# Dispatch ui-updated to downstream dependents
- name: Dispatch ui-updated to el-ide
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.neuralplatform.ai/api/v1
run: |
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_API}/repos/neuron-technologies/el-ide/dispatches" \
-d "{\"type\":\"ui-updated\",\"inputs\":{\"ui_version\":\"latest\",\"commit\":\"${GITHUB_SHA}\"}}"
echo "Dispatched ui-updated to neuron-technologies/el-ide"
Generated
-897
View File
@@ -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"
-17
View File
@@ -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"
+35 -3
View File
@@ -351,15 +351,16 @@ export class Router {
}
}
// ── Component base class & mount() ───────────────────────────────────────────
// ── Component base class, mount(), and hydrate() ─────────────────────────────
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this.props = props;
this._renderer = null;
this._ssrState = initialState;
}
setState(name, value) {
@@ -382,6 +383,20 @@ export class Component {
render() { return ''; }
onMount() {}
onHydrate() {}
_hydrate(root) {
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') this.onHydrate();
}
}
export function mount(ComponentClass, selector, props = {}) {
@@ -394,4 +409,21 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer, Router };
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) throw new Error(`el-ui hydrate: element not found: ${selector}`);
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
const instance = new ComponentClass(props, initialState);
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer, Router };
-16
View File
@@ -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" }
-350
View File
@@ -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::<String>("app.name").unwrap_or_default();
let app_version = config.get::<String>("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<u32> = 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.");
}
+88
View File
@@ -0,0 +1,88 @@
// ssr-counter/Counter.el
//
// A counter component that demonstrates:
// - State with initial value
// - {#if} conditional rendering
// - {#each} list rendering
// - Interpolation
// - Event binding
// - SSR + client hydration
//
// Run `node render.js Counter` to see the server-rendered HTML.
// Open index.html in a browser to see the full SSR + hydration lifecycle.
component Counter {
props {
initialValue: Int = 0
label: String = "Counter"
}
state {
count: Int = 0
history: String = ""
}
fn increment() -> Void {
count = count + 1
history = history + "+" + count + " "
}
fn decrement() -> Void {
count = count - 1
history = history + "-" + count + " "
}
fn reset() -> Void {
count = 0
history = ""
}
template {
<div class="counter">
<h2 class="counter-label">{label}</h2>
<div class="count-display">
<span class={count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}>
{count}
</span>
</div>
<div class="buttons">
<button class="btn btn-decrement" on:click={() => decrement()}>-</button>
<button class="btn btn-reset" on:click={() => reset()}>reset</button>
<button class="btn btn-increment" on:click={() => increment()}>+</button>
</div>
{#if history != ""}
<div class="history">
<span class="history-label">history:</span>
<code>{history}</code>
</div>
{/if}
{#if count == 0}
<p class="hint">Press + or - to begin.</p>
{/if}
</div>
}
}
component CounterPair {
template {
<div class="counter-pair">
<Counter label="Left" />
<Counter label="Right" />
</div>
}
}
component App {
template {
<div class="app">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<Counter label="Main Counter" />
<CounterPair />
</main>
</div>
}
}
+119
View File
@@ -0,0 +1,119 @@
import { Component, Graph, Renderer, Router, mount } from '../../dist/el-ui.js';
class Counter extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this._stateNodes['count'] = this._graph.seed({ type: 'state', name: 'count', content: 0 });
this._state['count'] = 0;
this._stateNodes['history'] = this._graph.seed({ type: 'state', name: 'history', content: "" });
this._state['history'] = "";
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
increment() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', count + 1)
__self.setState('history', history + "+" + count + " ")
}
decrement() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', count - 1)
__self.setState('history', history + "-" + count + " ")
}
reset() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', 0)
__self.setState('history', "")
}
render() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
return `<div class="counter" data-el-tag="div"><h2 class="counter-label" data-el-tag="h2">${label }</h2><div class="count-display" data-el-tag="div"><span class="${count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}" data-el-tag="span">${count }</span></div><div class="buttons" data-el-tag="div"><button class="btn btn-decrement" data-el-click="() => decrement()" data-el-tag="button">-</button><button class="btn btn-reset" data-el-click="() => reset()" data-el-tag="button">reset</button><button class="btn btn-increment" data-el-click="() => increment()" data-el-tag="button">+</button></div>${(history != "") ? `<div class="history" data-el-tag="div"><span class="history-label" data-el-tag="span">history:</span><code data-el-tag="code">${history }</code></div>` : ``}${(count == 0) ? `<p class="hint" data-el-tag="p">Press + or - to begin.</p>` : ``}</div>`;
}
}
class CounterPair extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
render() {
const __self = this;
return `<div class="counter-pair" data-el-tag="div">${__self._child(Counter, { label: "Left" })}${__self._child(Counter, { label: "Right" })}</div>`;
}
}
class App extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
render() {
const __self = this;
return `<div class="app" data-el-tag="div"><header class="app-header" data-el-tag="header"><h1 data-el-tag="h1">el-ui SSR + Hydration</h1><p class="subtitle" data-el-tag="p">Server-rendered, client-hydrated with spreading activation</p></header><main class="app-main" data-el-tag="main">${__self._child(Counter, { label: "Main Counter" })}${__self._child(CounterPair, { })}</main></div>`;
}
}
export { Counter, CounterPair, App };
+49
View File
@@ -0,0 +1,49 @@
/**
* hydrate.js — Client-side hydration entry point for ssr-counter.
*
* This script runs in the browser after the server has delivered the
* SSR-rendered HTML. It:
* 1. Finds each server-rendered component root (data-el-component attribute).
* 2. Reads back the initial state snapshot (data-el-state attribute).
* 3. Attaches the el-ui runtime WITHOUT replacing the existing DOM.
* 4. Binds all event handlers so the page becomes interactive.
*
* The page is immediately visible (the SSR HTML), and becomes interactive
* as soon as this script finishes executing. There is no content flash.
*
* SSR + hydration lifecycle:
* Server: renderToString(src, 'App', props) → HTML with markers
* Network: HTML delivered to browser, painted immediately
* Client: hydrate('[data-el-component="App"]', App) → events bound
* spreading activation activates on first setState() call
*/
import { hydrate } from '../../dist/el-ui.js';
import { Counter, CounterPair, App } from './app.js';
// Hydrate each component by its data-el-component selector.
// Order matters: hydrate leaf components before their parents so
// each instance gets its own renderer and state graph.
// Hydrate all Counter instances
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
// Read the stable ID to avoid double-hydration
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
el.dataset.elHydrated = 'true';
});
// Hydrate all CounterPair instances
document.querySelectorAll('[data-el-component="CounterPair"]').forEach(el => {
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, CounterPair);
el.dataset.elHydrated = 'true';
});
// The App component contains CounterPair and Counter children.
// If you want the App to own the full activation graph, hydrate it here.
// For this example we hydrate individual Counter components so each
// maintains its own independent state graph (the simpler model).
// Uncomment to hydrate the whole App as one unit instead:
// hydrate('[data-el-component="App"]', App);
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<!--
ssr-counter/index.html — el-ui SSR + Hydration Example
In production, the content of <div id="app"> is generated by the server
calling:
const { renderToString } = require('./render.js');
const html = renderToString('App', {});
This static file simulates that by inlining the rendered HTML directly.
The client hydration script (hydrate.js) attaches to the existing DOM
without replacing it.
To see server rendering live:
node render.js App > /tmp/rendered.html
# then inline that output into the #app div below
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr-counter — el-ui SSR + Hydration</title>
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--text: #e8edf3;
--text2: #7a8a9a;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.4);
--green: #4ade80;
--red: #f87171;
--border: rgba(255,255,255,0.08);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', 'JetBrains Mono', 'Fira Code', monospace;
min-height: 100vh;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
}
.app-header {
text-align: center;
margin-bottom: 48px;
}
.app-header h1 {
font-size: 32px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.03em;
margin-bottom: 8px;
}
.subtitle {
color: var(--text2);
font-size: 14px;
}
.app-main {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
.counter {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px 40px;
min-width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.counter-label {
font-size: 14px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
.count-display {
display: flex;
align-items: center;
justify-content: center;
}
.count {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 120px;
text-align: center;
letter-spacing: -0.03em;
transition: color 0.15s;
}
.count.zero { color: var(--text2); }
.count.positive { color: var(--accent); text-shadow: 0 0 40px var(--accent-glow); }
.count.negative { color: var(--red); text-shadow: 0 0 40px rgba(248,113,113,0.4); }
.buttons {
display: flex;
gap: 12px;
}
.btn {
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent);
font-size: 18px;
font-family: inherit;
padding: 0 20px;
height: 48px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: rgba(56,189,248,0.22);
box-shadow: 0 0 16px var(--accent-glow);
}
.btn:active { transform: scale(0.95); }
.btn-reset {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(255,255,255,0.04);
border-color: var(--border);
color: var(--text2);
}
.btn-reset:hover {
background: rgba(255,255,255,0.08);
box-shadow: none;
}
.btn-decrement {
color: var(--red);
border-color: var(--red);
background: rgba(248,113,113,0.08);
}
.btn-decrement:hover {
background: rgba(248,113,113,0.18);
box-shadow: 0 0 16px rgba(248,113,113,0.3);
}
.history {
font-size: 12px;
color: var(--text2);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.history-label {
color: var(--text2);
opacity: 0.6;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
code {
color: var(--accent);
font-family: inherit;
}
.hint {
font-size: 12px;
color: var(--text2);
opacity: 0.5;
}
.counter-pair {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.el-badge {
position: fixed;
bottom: 16px;
right: 16px;
font-size: 10px;
color: var(--text2);
opacity: 0.5;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* SSR indicator — shows before hydration completes */
.ssr-note {
text-align: center;
font-size: 11px;
color: var(--text2);
opacity: 0.4;
margin-bottom: 24px;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<!--
In a real server setup, this div's content is injected by the server:
const { renderToString } = require('./render.js');
res.send(`<div id="app">${renderToString('App', {})}</div>`);
To preview server output:
node render.js App
Below is the static simulation of that output.
The data-el-* attributes are the hydration markers written by the SSR engine.
-->
<div id="app">
<!-- SSR output placeholder. Run: node render.js App and paste here. -->
<!-- Or open this file with a server that injects the SSR output above. -->
<!--
Example of what the server renders (simplified for readability):
<div class="app"
data-el-component="App"
data-el-id="el-app-1"
data-el-state="{}">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<div class="counter"
data-el-component="Counter"
data-el-id="el-counter-1"
data-el-state='{"count":0,"history":""}'>
...
</div>
</main>
</div>
-->
</div>
<p class="ssr-note">el-ui SSR + hydration · no DOM replacement on hydrate</p>
<div class="el-badge">el-ui · spreading activation</div>
<!--
Hydration script.
- app.js: the compiled component classes (web target, for render() and setState())
- hydrate.js: attaches the runtime to the server-rendered DOM
Both are type="module" so they run after the document is parsed.
The SSR HTML is already painted before these scripts execute.
-->
<script type="module" src="./hydrate.js"></script>
<!--
Integration note for the El web server:
===========================================
The El web server (C binary) renders pages by calling:
node render.js App '{}'
and capturing stdout as the HTML to inject into the template above.
This is the simplest bridge: no Node.js server needed, just exec().
A tighter integration embeds Node.js (via libnode or napi) and calls
renderToString() directly without subprocess overhead — appropriate for
high-traffic pages. The render script API is identical either way.
-->
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
/**
* el-ui SSR render script — generated by elc-ui --target=server
* Source: Counter.el
*
* Exports:
* renderToString(componentName, props) -> HTML string
* renderToDocument(componentName, props, opts) -> full HTML document
* componentNames -> string[]
*/
'use strict';
const { renderToString: _rts, renderToDocument: _rtd, parseComponents } = require('../../runtime/src/ssr.js');
// Component source embedded at compile time
const _src = Buffer.from('Ly8gc3NyLWNvdW50ZXIvQ291bnRlci5lbAovLwovLyBBIGNvdW50ZXIgY29tcG9uZW50IHRoYXQgZGVtb25zdHJhdGVzOgovLyAgIC0gU3RhdGUgd2l0aCBpbml0aWFsIHZhbHVlCi8vICAgLSB7I2lmfSBjb25kaXRpb25hbCByZW5kZXJpbmcKLy8gICAtIHsjZWFjaH0gbGlzdCByZW5kZXJpbmcKLy8gICAtIEludGVycG9sYXRpb24KLy8gICAtIEV2ZW50IGJpbmRpbmcKLy8gICAtIFNTUiArIGNsaWVudCBoeWRyYXRpb24KLy8KLy8gUnVuIGBub2RlIHJlbmRlci5qcyBDb3VudGVyYCB0byBzZWUgdGhlIHNlcnZlci1yZW5kZXJlZCBIVE1MLgovLyBPcGVuIGluZGV4Lmh0bWwgaW4gYSBicm93c2VyIHRvIHNlZSB0aGUgZnVsbCBTU1IgKyBoeWRyYXRpb24gbGlmZWN5Y2xlLgoKY29tcG9uZW50IENvdW50ZXIgewogICAgcHJvcHMgewogICAgICAgIGluaXRpYWxWYWx1ZTogSW50ID0gMAogICAgICAgIGxhYmVsOiBTdHJpbmcgPSAiQ291bnRlciIKICAgIH0KCiAgICBzdGF0ZSB7CiAgICAgICAgY291bnQ6IEludCA9IDAKICAgICAgICBoaXN0b3J5OiBTdHJpbmcgPSAiIgogICAgfQoKICAgIGZuIGluY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgKyAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiKyIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIGRlY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgLSAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiLSIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIHJlc2V0KCkgLT4gVm9pZCB7CiAgICAgICAgY291bnQgPSAwCiAgICAgICAgaGlzdG9yeSA9ICIiCiAgICB9CgogICAgdGVtcGxhdGUgewogICAgICAgIDxkaXYgY2xhc3M9ImNvdW50ZXIiPgogICAgICAgICAgICA8aDIgY2xhc3M9ImNvdW50ZXItbGFiZWwiPntsYWJlbH08L2gyPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjb3VudC1kaXNwbGF5Ij4KICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPXtjb3VudCA+IDAgPyAiY291bnQgcG9zaXRpdmUiIDogY291bnQgPCAwID8gImNvdW50IG5lZ2F0aXZlIiA6ICJjb3VudCB6ZXJvIn0+CiAgICAgICAgICAgICAgICAgICAge2NvdW50fQogICAgICAgICAgICAgICAgPC9zcGFuPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGRpdiBjbGFzcz0iYnV0dG9ucyI+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWRlY3JlbWVudCIgb246Y2xpY2s9eygpID0+IGRlY3JlbWVudCgpfT4tPC9idXR0b24+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLXJlc2V0IiBvbjpjbGljaz17KCkgPT4gcmVzZXQoKX0+cmVzZXQ8L2J1dHRvbj4KICAgICAgICAgICAgICAgIDxidXR0b24gY2xhc3M9ImJ0biBidG4taW5jcmVtZW50IiBvbjpjbGljaz17KCkgPT4gaW5jcmVtZW50KCl9Pis8L2J1dHRvbj4KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIHsjaWYgaGlzdG9yeSAhPSAiIn0KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9Imhpc3RvcnkiPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJoaXN0b3J5LWxhYmVsIj5oaXN0b3J5Ojwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8Y29kZT57aGlzdG9yeX08L2NvZGU+CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgey9pZn0KICAgICAgICAgICAgeyNpZiBjb3VudCA9PSAwfQogICAgICAgICAgICAgICAgPHAgY2xhc3M9ImhpbnQiPlByZXNzICsgb3IgLSB0byBiZWdpbi48L3A+CiAgICAgICAgICAgIHsvaWZ9CiAgICAgICAgPC9kaXY+CiAgICB9Cn0KCmNvbXBvbmVudCBDb3VudGVyUGFpciB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iY291bnRlci1wYWlyIj4KICAgICAgICAgICAgPENvdW50ZXIgbGFiZWw9IkxlZnQiIC8+CiAgICAgICAgICAgIDxDb3VudGVyIGxhYmVsPSJSaWdodCIgLz4KICAgICAgICA8L2Rpdj4KICAgIH0KfQoKY29tcG9uZW50IEFwcCB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iYXBwIj4KICAgICAgICAgICAgPGhlYWRlciBjbGFzcz0iYXBwLWhlYWRlciI+CiAgICAgICAgICAgICAgICA8aDE+ZWwtdWkgU1NSICsgSHlkcmF0aW9uPC9oMT4KICAgICAgICAgICAgICAgIDxwIGNsYXNzPSJzdWJ0aXRsZSI+U2VydmVyLXJlbmRlcmVkLCBjbGllbnQtaHlkcmF0ZWQgd2l0aCBzcHJlYWRpbmcgYWN0aXZhdGlvbjwvcD4KICAgICAgICAgICAgPC9oZWFkZXI+CiAgICAgICAgICAgIDxtYWluIGNsYXNzPSJhcHAtbWFpbiI+CiAgICAgICAgICAgICAgICA8Q291bnRlciBsYWJlbD0iTWFpbiBDb3VudGVyIiAvPgogICAgICAgICAgICAgICAgPENvdW50ZXJQYWlyIC8+CiAgICAgICAgICAgIDwvbWFpbj4KICAgICAgICA8L2Rpdj4KICAgIH0KfQo=', 'base64').toString('utf8');
// Parse component names at module load time
const _components = parseComponents(_src);
const componentNames = [..._components.keys()];
/**
* Render a component to an HTML string.
* @param {string} componentName
* @param {Object} [props={}]
* @returns {string}
*/
function renderToString(componentName, props = {}) {
return _rts(_src, componentName, props);
}
/**
* Render a component inside a full HTML document.
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @returns {string}
*/
function renderToDocument(componentName, props = {}, shellOpts = {}) {
return _rtd(_src, componentName, props, shellOpts);
}
module.exports = { renderToString, renderToDocument, componentNames };
// CLI mode: node render.js <ComponentName> [propsJson]
if (require.main === module) {
const compName = process.argv[2];
const propsJson = process.argv[3] || '{}';
if (!compName) {
console.error('Usage: node render.js <ComponentName> [propsJson]');
process.exit(1);
}
let props = {};
try { props = JSON.parse(propsJson); } catch (e) {
console.error('render.js: invalid props JSON:', e.message);
process.exit(1);
}
process.stdout.write(renderToString(compName, props));
}
+109 -4
View File
@@ -9,6 +9,7 @@
* Router — graph-based routing
* Component — base class for el-ui components
* mount() — attach a component to the DOM
* hydrate() — hydrate an SSR-rendered component without re-rendering
*
* @module el-ui
*/
@@ -30,14 +31,15 @@ import { Renderer } from './renderer.js';
* Base class for all el-ui components.
*
* Subclasses override:
* render() → returns HTML string
* onMount() → called after first mount (optional)
* render() → returns HTML string
* onMount() → called after first mount (optional)
* onHydrate() → called after hydration (optional)
*
* Subclasses call:
* setState(name, value) → update state, trigger activation, patch DOM
*/
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
/** @type {Graph} */
this._graph = new Graph();
@@ -52,6 +54,9 @@ export class Component {
/** @type {Renderer|null} */
this._renderer = null;
/** @type {Record<string, any>} initial state injected from SSR */
this._ssrState = initialState;
}
/**
@@ -105,6 +110,51 @@ export class Component {
* Override for side effects (timers, fetch calls, subscriptions).
*/
onMount() {}
/**
* Called once after client-side hydration completes.
* Override to run post-hydration side effects.
*/
onHydrate() {}
/**
* Hydrate this component instance onto an existing SSR-rendered DOM node.
*
* Does NOT call render() or replace innerHTML.
* Instead:
* 1. Seeds the Engram graph from the SSR initial state (already in _state
* because the constructor ran with the server's state values).
* 2. Sets up subscriptions so future setState() calls patch the DOM.
* 3. Binds all data-el-* event handlers via the renderer.
* 4. Calls onHydrate().
*
* This method is called by hydrate() after constructing the component
* instance. Subclasses rarely need to override it.
*
* @param {HTMLElement} root the SSR-rendered root element
*/
_hydrate(root) {
// Subscribe all state nodes so setState() → DOM patch works
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
// Create a renderer pointing at the SSR root.
// We record the current innerHTML as _currentHtml so the first
// real state change triggers a patch rather than a no-op comparison.
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
// Bind event handlers declared by the SSR output
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') {
this.onHydrate();
}
}
}
/**
@@ -133,4 +183,59 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer };
/**
* Hydrate a server-rendered el-ui component.
*
* Call this in the browser when the page was server-rendered and you want
* to attach the el-ui runtime to the existing DOM (rather than replacing it).
*
* The function:
* 1. Finds the SSR root element using `selector`.
* 2. Reads `data-el-state` from that element to recover the server's
* initial state snapshot.
* 3. Constructs the component with the recovered state pre-loaded.
* 4. Calls `instance._hydrate(root)` to bind events without re-rendering.
*
* @param {string} selector CSS selector for the SSR root element
* @param {typeof Component} ComponentClass the component class
* @param {Record<string, any>} [props={}] initial props (same as what server received)
* @returns {Component} the live, hydrated component instance
*
* @example
* import { hydrate } from './el-ui.js';
* import { Counter } from './counter.js';
*
* // Hydrate the SSR-rendered Counter at #app > [data-el-component="Counter"]
* hydrate('[data-el-component="Counter"]', Counter);
*/
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) {
throw new Error(`el-ui hydrate: element not found: ${selector}`);
}
// Recover initial state from the data-el-state attribute injected by SSR
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
// Construct the component; pass initialState so subclasses can pre-seed
// their state graph with the server values (avoids the flash of reset state)
const instance = new ComponentClass(props, initialState);
// Apply the recovered server state into the component's _state map.
// The compiled constructor already ran (seeding _stateNodes), so we
// update each state node's content to match the server snapshot.
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
// Directly update node content (no activation — just sync the value)
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer };
+951
View File
@@ -0,0 +1,951 @@
/**
* ssr.js — Server-side rendering engine for el-ui components.
*
* Parses .el component syntax and renders to HTML strings without a DOM.
* Runs in Node.js. Not a browser module.
*
* Architecture:
* ElParser — reads .el source, produces a component definition object
* SsrContext — holds props/state during a render pass
* renderNode — walks TemplateNode tree, emits HTML
* renderToString — top-level entry: component def + props -> HTML string
*
* Hydration markers emitted on every component root element:
* data-el-component="{ComponentName}"
* data-el-id="{stable_id}"
* data-el-state="{json}"
*
* These are consumed by hydrate() in the browser runtime.
*/
'use strict';
// ── HTML utilities ────────────────────────────────────────────────────────────
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
/**
* HTML-escape a string for text content or attribute values.
* @param {string} s
* @returns {string}
*/
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Generate a short stable ID for a component instance.
* In SSR we use a counter — the client uses the same position in the tree
* to match (position-based hydration).
* @param {string} name
* @param {number} idx
* @returns {string}
*/
function makeId(name, idx) {
return `el-${name.toLowerCase()}-${idx}`;
}
// ── Lexer ─────────────────────────────────────────────────────────────────────
/**
* Tokenize .el source into a flat list of tokens.
* This is a single-pass lexer that switches to template mode when it
* encounters `template {` and tracks brace depth for the block.
*
* Token shapes:
* { type: 'keyword', value: string }
* { type: 'ident', value: string }
* { type: 'string', value: string } -- unquoted value
* { type: 'number', value: number }
* { type: 'bool', value: boolean }
* { type: 'punct', value: string }
* { type: 'arrow', value: '->' }
* { type: 'op', value: string }
* { type: 'template_html', value: string } -- raw HTML content inside template {}
*
* @param {string} src
* @returns {Array<{type: string, value: any}>}
*/
function tokenize(src) {
const tokens = [];
let i = 0;
const KEYWORDS = new Set([
'component', 'props', 'state', 'fn', 'template',
'if', 'else', 'return', 'let',
]);
function peek(offset = 0) { return src[i + offset]; }
function advance() { return src[i++]; }
while (i < src.length) {
// Skip whitespace
if (/\s/.test(src[i])) { i++; continue; }
// Line comments
if (src[i] === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') i++;
continue;
}
// String literals
if (src[i] === '"') {
i++;
let s = '';
while (i < src.length && src[i] !== '"') {
if (src[i] === '\\' && src[i + 1] === '"') { s += '"'; i += 2; }
else s += advance();
}
i++; // closing "
tokens.push({ type: 'string', value: s });
continue;
}
// Numbers
if (/[0-9]/.test(src[i]) || (src[i] === '-' && /[0-9]/.test(src[i + 1]))) {
let n = '';
if (src[i] === '-') n += advance();
while (i < src.length && /[0-9.]/.test(src[i])) n += advance();
tokens.push({ type: 'number', value: parseFloat(n) });
continue;
}
// Arrow operator ->
if (src[i] === '-' && src[i + 1] === '>') {
tokens.push({ type: 'arrow', value: '->' });
i += 2;
continue;
}
// Identifiers and keywords
if (/[a-zA-Z_]/.test(src[i])) {
let word = '';
while (i < src.length && /[a-zA-Z0-9_-]/.test(src[i])) word += advance();
if (word === 'true') tokens.push({ type: 'bool', value: true });
else if (word === 'false') tokens.push({ type: 'bool', value: false });
else if (KEYWORDS.has(word)) tokens.push({ type: 'keyword', value: word });
else tokens.push({ type: 'ident', value: word });
continue;
}
// Colon (used in type annotations)
if (src[i] === ':') {
tokens.push({ type: 'punct', value: ':' });
i++;
continue;
}
// Single-char operators and punctuation
const ch = src[i];
if ('{}()[].,=!<>+-*/|&'.includes(ch)) {
// Two-char: !=, ==, >=, <=
const two = src.slice(i, i + 2);
if (['!=', '==', '>=', '<=', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two });
i += 2;
} else {
tokens.push({ type: 'punct', value: ch });
i++;
}
continue;
}
// Skip unknown chars
i++;
}
return tokens;
}
// ── Parser ────────────────────────────────────────────────────────────────────
/**
* Parse .el source into an array of component definitions.
*
* ComponentDef {
* name: string
* props: Array<PropDef> -- { name, type, defaultValue }
* state: Array<StateDef> -- { name, type, defaultValue }
* methods: Array<MethodDef> -- { name, params, returnType, body: string }
* template: TemplateNode | null
* }
*
* TemplateNode variants:
* { kind: 'element', tag, attrs, children }
* { kind: 'component', name, props }
* { kind: 'text', content }
* { kind: 'interpolation', expr }
* { kind: 'if', condition, then, else: TemplateNode[] | null }
* { kind: 'each', items, itemName, children }
* { kind: 'activate', query, resultName, children }
* { kind: 'raw_html', expr }
*
* @param {string} src
* @returns {Array<ComponentDef>}
*/
function parseEl(src) {
const components = [];
let pos = 0;
// ── Template parser (operates on raw source substring) ──────────────────
/**
* Parse the HTML-like template block content.
* We parse the raw source directly (not tokens) for the template section,
* because the template syntax (angle brackets, interpolations) is not
* compatible with the El token stream.
*
* @param {string} tmplSrc raw source inside `template { ... }`
* @returns {TemplateNode[]}
*/
function parseTemplate(tmplSrc) {
let ti = 0;
function skipWs() {
while (ti < tmplSrc.length && /\s/.test(tmplSrc[ti])) ti++;
}
function parseNodes() {
const nodes = [];
while (ti < tmplSrc.length) {
skipWs();
if (ti >= tmplSrc.length) break;
// Closing block tag: {/if}, {/each}, {/activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') break;
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') break;
// Block constructs: {#if}, {#each}, {#activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '#') {
const block = parseBlock();
if (block) nodes.push(block);
continue;
}
// Interpolation: {expr}
if (tmplSrc[ti] === '{') {
const interp = parseInterpolation();
if (interp) nodes.push(interp);
continue;
}
// Element: <tag ...>
if (tmplSrc[ti] === '<') {
// Closing tag — stop parsing children
if (tmplSrc[ti + 1] === '/') break;
const el = parseElement();
if (el) nodes.push(el);
continue;
}
// Text content
const text = parseText();
if (text && text.content.trim()) nodes.push(text);
}
return nodes;
}
function parseInterpolation() {
ti++; // skip {
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
return { kind: 'interpolation', expr: expr.trim() };
}
function parseBlock() {
ti += 2; // skip {#
let kw = '';
while (ti < tmplSrc.length && /[a-z]/.test(tmplSrc[ti])) kw += tmplSrc[ti++];
if (kw === 'if') {
// Read condition up to }
let cond = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') cond += tmplSrc[ti++];
ti++; // skip }
const thenBranch = parseNodes();
let elseBranch = null;
skipWs();
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') {
// skip {:else}
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
elseBranch = parseNodes();
}
skipWs();
// {/if}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'if', condition: cond.trim(), then: thenBranch, else: elseBranch };
}
if (kw === 'each') {
// {#each items as item}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
// parse "items as item"
const asMatch = rest.trim().match(/^(.+?)\s+as\s+(\w+)$/);
const itemsExpr = asMatch ? asMatch[1].trim() : rest.trim();
const itemName = asMatch ? asMatch[2] : 'item';
const children = parseNodes();
skipWs();
// {/each}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'each', items: itemsExpr, itemName, children };
}
if (kw === 'activate') {
// {#activate "query" as result}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
const actMatch = rest.trim().match(/^"(.+?)"\s+as\s+(\w+)$/);
const query = actMatch ? actMatch[1] : rest.trim();
const resultName = actMatch ? actMatch[2] : 'result';
const children = parseNodes();
skipWs();
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'activate', query, resultName, children };
}
// Unknown block — skip to closing
return null;
}
function parseElement() {
ti++; // skip <
// Read tag name
let tag = '';
while (ti < tmplSrc.length && /[a-zA-Z0-9_-]/.test(tmplSrc[ti])) tag += tmplSrc[ti++];
// Is this an uppercase component?
const isComponent = /^[A-Z]/.test(tag);
// Parse attributes
const attrs = parseAttrs();
skipWs();
// Self-closing: /> or void element
if (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>') {
ti += 2;
if (isComponent) return { kind: 'component', name: tag, props: attrs };
return { kind: 'element', tag, attrs, children: [] };
}
if (tmplSrc[ti] === '>') {
ti++; // skip >
}
if (isComponent) {
// Components are always self-closing in practice; if not, skip content
return { kind: 'component', name: tag, props: attrs };
}
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
return { kind: 'element', tag, attrs, children: [] };
}
// Parse children
const children = parseNodes();
// Skip closing tag </tag>
skipWs();
if (tmplSrc[ti] === '<' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '>') ti++;
ti++; // skip >
}
return { kind: 'element', tag, attrs, children };
}
function parseAttrs() {
const attrs = [];
while (ti < tmplSrc.length) {
skipWs();
if (tmplSrc[ti] === '>' || (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>')) break;
// Read attribute name (supports on:click etc.)
let attrName = '';
while (ti < tmplSrc.length && !/[\s=>\/]/.test(tmplSrc[ti])) {
attrName += tmplSrc[ti++];
}
if (!attrName) break;
skipWs();
if (tmplSrc[ti] !== '=') {
// Boolean attribute with no value
attrs.push({ name: attrName, type: 'bool', expr: 'true' });
continue;
}
ti++; // skip =
// Attribute value
if (tmplSrc[ti] === '"') {
// Static string value
ti++;
let val = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '"') val += tmplSrc[ti++];
ti++; // closing "
attrs.push({ name: attrName, type: 'static', value: val });
} else if (tmplSrc[ti] === '{') {
// Dynamic expression value
ti++;
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
attrs.push({ name: attrName, type: 'dynamic', expr: expr.trim() });
}
}
return attrs;
}
function parseText() {
let text = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '<' && tmplSrc[ti] !== '{') {
text += tmplSrc[ti++];
}
return text ? { kind: 'text', content: text } : null;
}
return parseNodes();
}
// ── Component-level parser ───────────────────────────────────────────────
// Find all component blocks using brace tracking
const componentRegex = /component\s+(\w+)\s*\{/g;
let match;
while ((match = componentRegex.exec(src)) !== null) {
const name = match[1];
const bodyStart = match.index + match[0].length;
// Find the matching closing brace
let depth = 1;
let p = bodyStart;
while (p < src.length && depth > 0) {
if (src[p] === '{') depth++;
else if (src[p] === '}') depth--;
p++;
}
const bodyEnd = p - 1;
const body = src.slice(bodyStart, bodyEnd);
// Parse props block
const propsMatch = body.match(/props\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s);
const props = propsMatch ? parsePropOrStateBlock(propsMatch[1]) : [];
// Parse state block
const stateMatch = body.match(/state\s*\{([^}]*)\}/s);
const state = stateMatch ? parsePropOrStateBlock(stateMatch[1]) : [];
// Parse methods -- fn name(...) -> Type { body }
const methods = parseMethods(body);
// Parse template block -- capture raw content
const templateMatch = body.match(/template\s*\{/);
let templateNodes = null;
if (templateMatch) {
const tmplStart = templateMatch.index + templateMatch[0].length;
let tdepth = 1;
let tp = tmplStart;
while (tp < body.length && tdepth > 0) {
if (body[tp] === '{') tdepth++;
else if (body[tp] === '}') tdepth--;
tp++;
}
const tmplContent = body.slice(tmplStart, tp - 1);
templateNodes = parseTemplate(tmplContent);
}
components.push({ name, props, state, methods, template: templateNodes });
}
return components;
}
/**
* Parse a props or state block into field definitions.
* Each line: name: Type [ = defaultValue ]
* @param {string} blockSrc
* @returns {Array<{name: string, type: string, defaultValue: any}>}
*/
function parsePropOrStateBlock(blockSrc) {
const fields = [];
const lines = blockSrc.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
// name: Type = default OR name: Type
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)(?:\s*=\s*(.+))?$/);
if (!m) continue;
const [, fieldName, typeName, defaultRaw] = m;
let defaultValue = undefined;
if (defaultRaw !== undefined) {
const dv = defaultRaw.trim();
if (dv === 'true') defaultValue = true;
else if (dv === 'false') defaultValue = false;
else if (dv.startsWith('"') && dv.endsWith('"')) defaultValue = dv.slice(1, -1);
else if (!isNaN(parseFloat(dv))) defaultValue = parseFloat(dv);
else defaultValue = dv;
} else {
// Default by type
if (typeName === 'Int' || typeName === 'Float') defaultValue = 0;
else if (typeName === 'Bool') defaultValue = false;
else if (typeName === 'String') defaultValue = '';
else defaultValue = null;
}
fields.push({ name: fieldName, type: typeName, defaultValue });
}
return fields;
}
/**
* Parse method definitions from a component body.
* @param {string} bodySrc
* @returns {Array<{name: string, body: string}>}
*/
function parseMethods(bodySrc) {
const methods = [];
const fnRegex = /fn\s+(\w+)\s*\([^)]*\)\s*->\s*\w+\s*\{/g;
let m;
while ((m = fnRegex.exec(bodySrc)) !== null) {
const name = m[1];
const bodyStart = m.index + m[0].length;
let depth = 1;
let p = bodyStart;
while (p < bodySrc.length && depth > 0) {
if (bodySrc[p] === '{') depth++;
else if (bodySrc[p] === '}') depth--;
p++;
}
const body = bodySrc.slice(bodyStart, p - 1);
methods.push({ name, body });
}
return methods;
}
// ── SSR Renderer ──────────────────────────────────────────────────────────────
/**
* Evaluation context for SSR rendering.
* Holds current component's state + props so expressions can be evaluated.
*/
class SsrContext {
/**
* @param {Object} state current state values
* @param {Object} props current prop values
* @param {Object} locals loop/block-scoped bindings
* @param {Map<string, ComponentDef>} components registry for child component lookup
* @param {Object} counters per-component-name instance counters (for IDs)
*/
constructor(state, props, locals, components, counters) {
this.state = state || {};
this.props = props || {};
this.locals = locals || {};
this.components = components || new Map();
this.counters = counters || {};
}
/**
* Resolve a name to a value: check locals first, then state, then props.
* @param {string} name
* @returns {any}
*/
resolve(name) {
if (name in this.locals) return this.locals[name];
if (name in this.state) return this.state[name];
if (name in this.props) return this.props[name];
return undefined;
}
/**
* Evaluate a JavaScript expression in the context of this component's
* state and props. Uses Function construction with named bindings.
* Returns undefined if evaluation fails.
* @param {string} expr
* @returns {any}
*/
evalExpr(expr) {
if (!expr || !expr.trim()) return undefined;
try {
// Build an environment object with all available bindings
const env = { ...this.props, ...this.state, ...this.locals };
const keys = Object.keys(env);
const vals = keys.map(k => env[k]);
// eslint-disable-next-line no-new-func
const fn = new Function(...keys, `return (${expr});`);
return fn(...vals);
} catch (e) {
// Expression may reference DOM globals or complex code not available in SSR;
// return empty string rather than crashing
return '';
}
}
/**
* Create a child context with additional local bindings.
* @param {Object} additionalLocals
* @returns {SsrContext}
*/
withLocals(additionalLocals) {
return new SsrContext(
this.state,
this.props,
{ ...this.locals, ...additionalLocals },
this.components,
this.counters,
);
}
}
/**
* Render a single TemplateNode to an HTML string.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} [opts]
* @param {boolean} [opts.isRoot] if true, inject hydration attrs on the element
* @param {string} [opts.componentName]
* @param {string} [opts.componentId]
* @param {Object} [opts.initialState]
* @returns {string}
*/
function renderNode(node, ctx, opts = {}) {
if (!node) return '';
switch (node.kind) {
case 'text':
return escapeHtml(node.content);
case 'interpolation': {
const val = ctx.evalExpr(node.expr);
return escapeHtml(val === undefined || val === null ? '' : String(val));
}
case 'raw_html': {
const val = ctx.evalExpr(node.expr);
return val === undefined || val === null ? '' : String(val);
}
case 'if': {
const cond = ctx.evalExpr(node.condition);
const branch = cond ? node.then : (node.else || []);
return branch.map(n => renderNode(n, ctx)).join('');
}
case 'each': {
let items = ctx.evalExpr(node.items);
if (!Array.isArray(items)) {
// Try resolving as a simple name
items = ctx.resolve(node.items);
}
if (!Array.isArray(items)) return '';
return items.map(item => {
const childCtx = ctx.withLocals({ [node.itemName]: item });
return node.children.map(n => renderNode(n, childCtx)).join('');
}).join('');
}
case 'activate': {
// SSR: activate is a no-op — return empty (no Engram server available)
// The block still renders with an empty result set, which is correct:
// the client will re-run the semantic query once the graph is hydrated.
return '';
}
case 'component': {
const compDef = ctx.components.get(node.name);
if (!compDef) {
return `<!-- el-ui SSR: unknown component ${escapeHtml(node.name)} -->`;
}
// Resolve props for the child component
const childProps = {};
for (const attr of node.props) {
if (attr.type === 'static') {
childProps[attr.name] = attr.value;
} else if (attr.type === 'dynamic') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
} else if (attr.type === 'bool') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
}
}
// Build initial state for child
const childState = {};
for (const s of compDef.state) {
childState[s.name] = childProps[s.name] !== undefined ? childProps[s.name] : s.defaultValue;
}
// Apply prop defaults for child
const resolvedChildProps = {};
for (const p of compDef.props) {
resolvedChildProps[p.name] = childProps[p.name] !== undefined ? childProps[p.name] : p.defaultValue;
}
// Assign child instance ID
const cname = node.name;
ctx.counters[cname] = (ctx.counters[cname] || 0) + 1;
const cid = makeId(cname, ctx.counters[cname]);
return renderComponent(compDef, resolvedChildProps, childState, ctx.components, ctx.counters, cid);
}
case 'element': {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, opts);
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
default:
return '';
}
}
/**
* Render an attribute list to a string fragment.
* For event binding attributes (on:click etc.), emits data-el-{event} for
* the client hydration pass to bind — does NOT execute the handler in SSR.
* For root element, injects hydration markers.
*
* @param {Array} attrs
* @param {SsrContext} ctx
* @param {Object} opts
* @returns {string}
*/
function renderAttrs(attrs, ctx, opts = {}) {
const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'readonly', 'required', 'multiple', 'selected']);
let out = '';
for (const attr of attrs) {
const name = attr.name;
// Event bindings: on:click -> data-el-click
if (name.startsWith('on:')) {
const event = name.slice(3);
const expr = attr.type === 'dynamic' ? attr.expr : attr.value || '';
out += ` data-el-${escapeHtml(event)}="${escapeHtml(expr)}"`;
continue;
}
if (attr.type === 'static') {
out += ` ${name}="${escapeHtml(attr.value)}"`;
} else if (attr.type === 'dynamic') {
const val = ctx.evalExpr(attr.expr);
if (val === null || val === false || val === undefined) {
// Omit the attribute
} else if (BOOLEAN_ATTRS.has(name)) {
if (val) out += ` ${name}`;
} else {
out += ` ${name}="${escapeHtml(String(val))}"`;
}
} else if (attr.type === 'bool') {
const val = ctx.evalExpr(attr.expr);
if (val) out += ` ${name}`;
}
}
// Inject hydration markers on the root element
if (opts.isRoot) {
out += ` data-el-component="${escapeHtml(opts.componentName)}"`;
out += ` data-el-id="${escapeHtml(opts.componentId)}"`;
if (opts.initialState !== undefined) {
out += ` data-el-state="${escapeHtml(JSON.stringify(opts.initialState))}"`;
}
}
return out;
}
/**
* Render a component definition to an HTML string.
* The first element in the template receives hydration marker attributes.
*
* @param {ComponentDef} compDef
* @param {Object} props
* @param {Object} state
* @param {Map<string, ComponentDef>} components
* @param {Object} counters
* @param {string} [instanceId]
* @returns {string}
*/
function renderComponent(compDef, props, state, components, counters, instanceId) {
if (!compDef.template || compDef.template.length === 0) return '';
const ctx = new SsrContext(state, props, {}, components, counters);
// Find the root element node (first non-text node)
const rootIdx = compDef.template.findIndex(n => n.kind === 'element' || n.kind === 'component');
const parts = compDef.template.map((node, i) => {
if (i === rootIdx) {
// Inject hydration markers on root element
return renderNodeWithHydration(node, ctx, {
componentName: compDef.name,
componentId: instanceId || makeId(compDef.name, 1),
initialState: state,
});
}
return renderNode(node, ctx);
});
return parts.join('');
}
/**
* Render a node with hydration attributes injected on the outermost element.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} hydrationOpts
* @returns {string}
*/
function renderNodeWithHydration(node, ctx, hydrationOpts) {
if (!node) return '';
if (node.kind === 'element') {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, { isRoot: true, ...hydrationOpts });
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
// If root is a component reference, render it (hydration attrs will be on its root)
return renderNode(node, ctx);
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Parse a .el source file and return a map of component definitions.
* @param {string} src
* @returns {Map<string, ComponentDef>}
*/
function parseComponents(src) {
const defs = parseEl(src);
const map = new Map();
for (const def of defs) {
map.set(def.name, def);
}
return map;
}
/**
* Render a named component from .el source to an HTML string.
*
* @param {string} src .el source containing the component definition
* @param {string} componentName name of the component to render
* @param {Object} [props={}] initial props
* @returns {string} server-rendered HTML
*
* @example
* const html = renderToString(src, 'Counter', { initialValue: 5 });
* // => '<div data-el-component="Counter" data-el-id="el-counter-1" data-el-state="{...}">...'
*/
function renderToString(src, componentName, props = {}) {
const components = parseComponents(src);
const compDef = components.get(componentName);
if (!compDef) {
throw new Error(`el-ui SSR: component "${componentName}" not found in source`);
}
// Build initial state from state declarations + prop overrides
const initialState = {};
for (const s of compDef.state) {
initialState[s.name] = props[s.name] !== undefined ? props[s.name] : s.defaultValue;
}
// Resolve prop values (apply defaults)
const resolvedProps = {};
for (const p of compDef.props) {
resolvedProps[p.name] = props[p.name] !== undefined ? props[p.name] : p.defaultValue;
}
const counters = {};
counters[componentName] = 1;
const id = makeId(componentName, 1);
return renderComponent(compDef, resolvedProps, initialState, components, counters, id);
}
/**
* Render a named component and wrap in a full HTML document shell.
* Useful for testing or single-page SSR without a template engine.
*
* @param {string} src
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @param {string} [shellOpts.title='el-ui app']
* @param {string} [shellOpts.runtimePath='./el-ui.js']
* @param {string} [shellOpts.appScriptPath='./app.js']
* @param {string} [shellOpts.styles='']
* @returns {string}
*/
function renderToDocument(src, componentName, props = {}, shellOpts = {}) {
const {
title = 'el-ui app',
runtimePath = './el-ui.js',
appScriptPath = './app.js',
styles = '',
} = shellOpts;
const body = renderToString(src, componentName, props);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>${styles ? `\n<style>${styles}</style>` : ''}
</head>
<body>
<div id="app">${body}</div>
<script type="module" src="${escapeHtml(appScriptPath)}"></script>
</body>
</html>`;
}
module.exports = {
renderToString,
renderToDocument,
parseComponents,
parseEl,
escapeHtml,
VOID_ELEMENTS,
};
+203 -13
View File
@@ -1,6 +1,6 @@
# el-ui Framework Specification
Version 1.1.0 — April 30, 2026
Version 1.2.0 — May 4, 2026
---
@@ -516,11 +516,13 @@ For each component:
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
8. Emits `${__self._child(ComponentClass, props)}` for component references.
#### `CodegenTarget::Server` — Rust SSR
#### `CodegenTarget::Server` — Node.js SSR
Emits a Rust module where each component is a struct implementing `render_to_html(&self) -> String` via `el_platform::ServerBackend`.
Emits a Node.js CommonJS module (the "render script") that exports `renderToString(componentName, props)` and `renderToDocument(componentName, props, shellOpts)`. The render script uses `runtime/src/ssr.js` to parse the component source at module load time and render to HTML strings at call time.
**Note:** The `build_node_tree()` implementation in this target is a stub — it returns a placeholder `PlatformNode::element("div")`. Full template-to-PlatformNode codegen is not yet implemented for the server target.
The `build_node_tree()` / template-to-HTML pipeline is implemented in `runtime/src/ssr.js`. It covers all TemplateNode variants: `Element`, `Text`, `Interpolation`, `If`, `Each`, `Activate`, `Component`, and `RawHtml`. The `{#activate}` block renders empty on the server (no Engram server available at SSR time); the client runtime re-runs semantic queries after hydration.
The `ServerBackend` target is the only target where hydration markers are injected: `data-el-component`, `data-el-id`, and `data-el-state` on each component root element.
#### `CodegenTarget::Native(Platform)` — Rust Native / WASM
@@ -578,12 +580,26 @@ Appearance is inferred from explicit `appearance="..."` attributes, CSS class na
### 7.6 CLI
The `elc-ui` Node.js script in `vessels/el-ui-compiler/elc-ui` exposes the compiler as a command-line tool:
```bash
el-ui-compiler App.el # compiles to App.js (Web target, default)
el-ui-compiler App.el -o app.js # explicit output path
elc-ui App.el # compile to App.js (web target, default)
elc-ui App.el -o app.js # explicit output path
elc-ui App.el --target=server # emit Node.js render script
elc-ui App.el --target=server -o render.js
elc-ui App.el --runtime=../el-ui.js # custom runtime path for web target
```
The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `--target ios`, etc.) is available as a `CodegenTarget` enum in the library API but is not exposed as a CLI flag in the current implementation.
**Web target** emits an ES2022 JavaScript module — the same class-per-component format as the hand-compiled examples in `examples/counter/app.js`. Requires the el-ui runtime (`el-ui.js` or `dist/el-ui.js`) to be available at the path specified by `--runtime`.
**Server target** emits a Node.js CJS module that:
- Embeds the component source at compile time (base64-encoded)
- Exports `renderToString(componentName, props)` → HTML string
- Exports `renderToDocument(componentName, props, shellOpts)` → full HTML document
- Exports `componentNames` → array of component names found in the source
- Supports CLI invocation: `node render.js ComponentName '{"prop":"val"}'`
The server render script is self-contained: it does not require re-reading the `.el` source file at render time. The El web server calls it via `exec()` and captures stdout as the rendered HTML string.
---
@@ -597,15 +613,26 @@ The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `
| `activation.js` | Standalone activation utilities: `spreadActivation`, `activationStrength`, `reachableNodes` |
| `renderer.js` | DOM patching, event binding |
| `router.js` | Graph-based routing |
| `index.js` | `Component` base class, `mount()`, re-exports |
| `index.js` | `Component` base class, `mount()`, `hydrate()`, re-exports |
| `ssr.js` | Server-side rendering engine (Node.js only, not a browser module) |
### 8.2 Component Lifecycle
**Client-side (mount path):**
| Lifecycle event | Description |
|-----------------|-------------|
| `constructor(props)` | Seeds state graph, subscribes to activation |
| `onMount()` | Called after first render and DOM attachment |
| `constructor(props, initialState?)` | Seeds state graph, subscribes to activation. `initialState` is used by `hydrate()` to pre-load server state. |
| `render()` | Returns HTML string; called on activation |
| `onMount()` | Called after first render and DOM attachment |
**SSR + hydration path:**
| Lifecycle event | Description |
|-----------------|-------------|
| `constructor(props, initialState)` | Called by `hydrate()` with recovered server state |
| `_hydrate(root)` | Subscribes state nodes, creates renderer pointing at existing DOM, binds events. Does NOT call `render()`. |
| `onHydrate()` | Called after hydration completes |
**Note:** `onDestroy()` is documented in prior drafts but is not implemented in the base `Component` class or the `Renderer`. It will not be called by the runtime.
@@ -621,6 +648,73 @@ const component = mount(App, '#app', { optionalProp: 'value' });
`mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`.
### 8.5 Hydration
```javascript
import { hydrate } from './el-ui.js';
import { Counter } from './app.js';
// Hydrate a server-rendered Counter without replacing the DOM
const component = hydrate('[data-el-component="Counter"]', Counter);
// or with a stable ID for multi-instance pages:
const component = hydrate('[data-el-id="el-counter-1"]', Counter);
```
`hydrate` finds the SSR root element, reads `data-el-state` to recover the server's initial state, constructs the component with that state pre-loaded, then calls `_hydrate(root)` which binds all event handlers without re-rendering. The existing server-rendered DOM is preserved; spreading activation begins on the first `setState()` call.
**SSR lifecycle:**
```
Server: renderToString(src, 'App', props) → HTML with markers
Network: HTML delivered, painted immediately (zero JS latency)
Client: import { hydrate } from './el-ui.js'
hydrate('[data-el-component="App"]', App)
→ events bound, spreading activation active
→ subsequent setState() calls patch the DOM normally
```
### 8.6 Server-Side Rendering
The SSR engine in `runtime/src/ssr.js` is a Node.js module that parses `.el` source files and renders components to HTML strings:
```javascript
const { renderToString, renderToDocument, parseComponents } = require('./runtime/src/ssr.js');
const fs = require('fs');
const src = fs.readFileSync('App.el', 'utf8');
// Render to HTML fragment
const html = renderToString(src, 'App', { title: 'Hello' });
// Render to full document
const doc = renderToDocument(src, 'App', { title: 'Hello' }, {
title: 'My App',
appScriptPath: './hydrate.js',
styles: 'body { background: #000; }',
});
```
**Hydration markers** emitted on every component root element:
| Attribute | Value | Purpose |
|-----------|-------|---------|
| `data-el-component` | component name | identifies component type for `hydrate()` |
| `data-el-id` | stable position-based ID | locates the exact instance when multiple components of the same type exist |
| `data-el-state` | JSON string | server's initial state snapshot; `hydrate()` reads this to initialize the Engram graph |
Event bindings are preserved as `data-el-*` attributes (e.g., `data-el-click="() => increment()"`). The client hydration pass binds these handlers via `new Function('__self', ...)` exactly as the standard mount path does.
**Server/client bridge:**
The El web server calls the render script as a subprocess:
```bash
node render.js ComponentName '{"prop":"value"}'
# stdout: the rendered HTML string
```
The render script is generated by `elc-ui --target=server`. It embeds the component source at compile time, so no `.el` file is needed at render time.
### 8.4 DOM Patching Strategy (v0.1)
The renderer uses full string re-render on every state change: `root.innerHTML = component.render()`. The activated node set argument to `patch()` is accepted but unused — targeted patching is planned for v0.2.
@@ -653,7 +747,7 @@ pub trait PlatformBackend: Send + Sync {
}
```
The `ServerBackend` is the only backend where `supports_ssr()` returns `true` and `render_to_string()` produces real HTML output. All other backends are platform-native.
The `ServerBackend` is the only backend where `supports_ssr()` returns `true`. In v0.1, the server rendering implementation is in `runtime/src/ssr.js` (Node.js), not in a Rust crate. The `PlatformBackend` interface above describes the planned Rust abstraction; the current implementation fulfills the same contract via the JavaScript SSR engine, which is the authoritative server rendering path. All other backends are platform-native.
Platform is selected via `manifest.el`:
@@ -691,11 +785,107 @@ No prior framework uses Hebbian spreading activation as the re-render decision m
---
## 11. Versioning Roadmap
## 11. SSR + Hydration Example
A complete SSR + hydration example lives in `examples/ssr-counter/`.
### 11.1 Counter.el
The component definition with state, methods, `{#if}` conditionals, and child composition:
```
component Counter {
props {
label: String = "Counter"
}
state {
count: Int = 0
history: String = ""
}
fn increment() -> Void {
count = count + 1
history = history + "+" + count + " "
}
template {
<div class="counter">
<h2>{label}</h2>
<span class={count > 0 ? "count positive" : "count zero"}>{count}</span>
<button on:click={() => increment()}>+</button>
{#if history != ""}
<code>{history}</code>
{/if}
</div>
}
}
```
### 11.2 Compile
```bash
# Server render script (embed source, export renderToString)
elc-ui Counter.el --target=server -o render.js
# Browser module (class-per-component, for hydration)
elc-ui Counter.el --target=web --runtime=../../dist/el-ui.js -o app.js
```
### 11.3 Server Render
```bash
# Render to HTML fragment
node render.js Counter
# => <div class="counter" data-el-component="Counter" data-el-id="el-counter-1"
# data-el-state='{"count":0,"history":""}'>
# <h2>Counter</h2>
# <span class="count zero">0</span>
# <button data-el-click="() => increment()">+</button>
# <p class="hint">Press + or - to begin.</p>
# </div>
```
Or from Node.js:
```javascript
const { renderToString } = require('./render.js');
const html = renderToString('Counter', { label: 'Score' });
```
### 11.4 Client Hydration
```javascript
// hydrate.js — runs in browser after SSR HTML is painted
import { hydrate } from '../../dist/el-ui.js';
import { Counter } from './app.js';
// Find each server-rendered Counter and attach the runtime
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
el.dataset.elHydrated = 'true';
});
```
### 11.5 Full Lifecycle
```
1. Server calls: node render.js App
2. Server injects: rendered HTML into <div id="app">
3. Browser: HTML painted immediately (no JS needed for first paint)
4. Browser: <script type="module" src="./hydrate.js"> executes
5. hydrate.js: reads data-el-state, constructs Counter with server state
6. hydrate.js: binds data-el-click handlers without re-rendering DOM
7. User clicks +: count = count + 1 → setState → spreading activation → patch
```
The DOM is never blank. The page is interactive as soon as hydrate.js finishes executing. State is continuous across the SSR/hydration boundary because `data-el-state` carries the exact initial values the server used.
---
## 12. Versioning Roadmap
| Version | Key changes |
|---------|-------------|
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. JS (Web) target only via CLI. Native targets available via library API but `build_node_tree()` is stub. |
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. Web and Server targets via `elc-ui` CLI. SSR engine implemented (`runtime/src/ssr.js`). `hydrate()` function in client runtime. Native targets available via library API but `build_node_tree()` stub. |
| v0.2.x | Targeted DOM patching (only nodes in activation surface). `{#activate}` with embedding-based semantic search. CLI `--target` flag for native targets. Full AST→semantic→PlatformNode lowering. |
| v0.3.x | WASM-compiled Engram core replacing JavaScript graph. Sealed artifact support. `onDestroy()` lifecycle. |
| v1.0.0 | Stable API. Full production sealing. LSP integration with spreading activation autocomplete. |
-15
View File
@@ -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]
-490
View File
@@ -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<String>) -> 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<HashMap<String, CacheEntry>>,
}
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::<Vec<_>>()
.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<InvocationResult> {
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<HashMap<String, RateWindow>>,
}
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<String>) -> Self {
Self { level: level.into() }
}
}
impl Aspect for LogAspect {
fn name(&self) -> &'static str {
"log"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
// 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<HashMap<String, Vec<(String, String)>>>,
}
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<String>,
rule: impl Into<String>,
) {
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<InvocationResult> {
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<String>) -> 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<InvocationResult> {
// 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
})
}
}
-122
View File
@@ -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<Arc<dyn Aspect>>,
}
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<dyn Aspect>) -> 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<InvocationResult> {
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<InvocationResult> {
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<dyn Aspect>];
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()
}
}
-157
View File
@@ -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<T> = Result<T, AopError>;
/// 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<String, String>,
/// Metadata added by aspects (e.g., the authenticated user, trace ID).
pub metadata: HashMap<String, String>,
}
impl InvocationContext {
pub fn new(target: impl Into<String>, method: impl Into<String>) -> Self {
Self {
target: target.into(),
method: method.into(),
args: HashMap::new(),
metadata: HashMap::new(),
}
}
pub fn with_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.args.insert(key.into(), value.into());
self
}
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> 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<String, String>,
}
impl InvocationResult {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
metadata: HashMap::new(),
}
}
}
/// A handler that performs the actual method invocation.
/// Aspects wrap around this.
pub type ProceedFn = Box<dyn Fn(InvocationContext) -> AopResult<InvocationResult> + 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<InvocationResult>,
) -> AopResult<InvocationResult> {
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<InvocationResult> {
self.before(&mut ctx)?;
let result = proceed(ctx.clone());
self.after(&ctx, result)
}
}
-57
View File
@@ -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")
}
}
-284
View File
@@ -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<dyn Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync>;
/// Registry of aspect factories, indexed by decorator name.
pub struct AspectRegistry {
factories: RwLock<HashMap<String, AspectFactory>>,
/// When `true`, the registry is configured to prepend AuthenticateAspect
/// to every non-`@public` chain via `AspectChain::with_default_auth()`.
default_auth_enabled: RwLock<bool>,
/// Names that are bypass markers (not real aspects). Currently just "public".
bypass_markers: RwLock<std::collections::HashSet<String>>,
}
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<String, String>)]) -> 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<String, String>) -> Arc<dyn Aspect> + 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<String, String>,
) -> Option<Arc<dyn Aspect>> {
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<String> {
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");
}
}
-305
View File
@@ -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<String> + Clone) -> crate::ProceedFn {
let v = value.into();
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
}
fn fail_proceed(msg: impl Into<String> + 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<std::sync::Mutex<Vec<&'static str>>>,
}
impl Aspect for OrderTracker {
fn name(&self) -> &'static str { self.name }
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
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", &params);
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");
}
}
-20
View File
@@ -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]
-78
View File
@@ -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<String>,
email: impl Into<String>,
name: impl Into<String>,
) -> 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<AuthUser>,
pub roles: Vec<String>,
pub permissions: Vec<String>,
/// 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<String>, token: impl Into<String>) -> Self {
Self {
user: Some(user),
roles,
permissions: Vec::new(),
token: token.into(),
}
}
pub fn with_permissions(mut self, perms: Vec<String>) -> 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())
}
}
-128
View File
@@ -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<dyn EngramClient>` — 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<SessionManager>,
client: Arc<dyn EngramClient>,
}
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<dyn EngramClient>) -> 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<dyn EngramClient>, 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<SessionManager> {
&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<AuthContext> {
// 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<String> = 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<String> {
// 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()))
}
}
-314
View File
@@ -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<Sha256>;
/// 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<String>,
/// 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<String>,
pub iat: u64, // issued-at (unix seconds)
pub exp: u64, // expiry (unix seconds)
}
impl JwtClaims {
pub fn new(user: &AuthUser, roles: Vec<String>, 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<String>,
session_id: impl Into<String>,
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::<Vec<_>>()
.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<Self> {
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<u8>,
/// Token TTL in seconds (default: 3600 = 1 hour).
pub ttl_seconds: u64,
}
impl JwtProvider {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self { secret: secret.into(), ttl_seconds: 3600 }
}
pub fn from_env(env_var: &str) -> AuthResult<Self> {
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<JwtClaims> {
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<AuthContext> {
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<String> {
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<Vec<u8>> {
// 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<Vec<u8>> {
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<String> {
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<u64> {
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<String> {
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()
}
-68
View File
@@ -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<T> = Result<T, AuthError>;
/// 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<AuthContext>;
/// Issue a new token/session for an authenticated user.
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
/// Revoke a token/session (for logout).
fn revoke(&self, token: &str) -> AuthResult<()>;
}
-59
View File
@@ -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<dyn AuthProvider>,
}
impl AuthMiddleware {
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
Self { provider }
}
/// Extract and verify the auth token from an Authorization header value.
///
/// Supported formats:
/// - `Bearer <token>` — JWT or opaque token
/// - `Session <session_id>` — server-side session
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
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<AuthContext> {
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()
}
}
-90
View File
@@ -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<String>) -> 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<Permission>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(Permission::new(perm));
self
}
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> 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<String, Role>,
}
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()
}
}
-105
View File
@@ -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<HashMap<String, SessionEntry>>,
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<AuthContext> {
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<String> {
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(())
}
}
-252
View File
@@ -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, &reg).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(), &reg).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(), &reg).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");
}
}
-18
View File
@@ -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]
-337
View File
@@ -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.<current>] 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<Box<dyn ConfigSource>>,
/// 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<dyn ConfigSource>) {
self.sources.push(source);
}
/// Add a source at the highest priority (beginning of the stack).
pub fn prepend_source(&mut self, source: Box<dyn ConfigSource>) {
self.sources.insert(0, source);
}
/// Add defaults as the lowest-priority source.
pub fn set_defaults(&mut self, defaults: HashMap<String, String>) {
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<String> {
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<T: FromConfigStr>(&self, key: &str) -> Result<T, ConfigError> {
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<T: FromConfigStr>(&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<T: FromConfigStr>(&self, key: &str) -> Result<Option<T>, 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<String, String> {
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.<environment>]`.
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
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.<name>] 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<String, String>,
) {
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::<String>("app.name").unwrap(), "TestApp");
}
#[test]
fn get_u32() {
let cfg = make_config(&[("server.port", "8080")]);
assert_eq!(cfg.get::<u32>("server.port").unwrap(), 8080u32);
}
#[test]
fn get_bool() {
let cfg = make_config(&[("feature.enabled", "true")]);
assert_eq!(cfg.get::<bool>("feature.enabled").unwrap(), true);
}
#[test]
fn get_missing_returns_error() {
let cfg = Config::new(Environment::Development);
assert!(cfg.get::<String>("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::<String>("key").unwrap(), "high-value");
}
#[test]
fn get_opt_missing_is_none() {
let cfg = Config::new(Environment::Development);
assert_eq!(cfg.get_opt::<String>("missing").unwrap(), None);
}
#[test]
fn get_opt_present_is_some() {
let cfg = make_config(&[("key", "value")]);
assert_eq!(
cfg.get_opt::<String>("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());
}
}
-114
View File
@@ -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");
}
}
-20
View File
@@ -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 },
}
-42
View File
@@ -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.<current>]` 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::<String>("app.name").unwrap();
//! let port = cfg.get::<u32>("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::*;
-248
View File
@@ -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<String>;
/// All key→value pairs from this source.
fn all(&self) -> HashMap<String, String>;
}
/// 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<String>) -> 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<String> {
std::env::var(self.env_key(key)).ok()
}
fn all(&self) -> HashMap<String, String> {
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<String, String>,
}
impl MapSource {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
values: HashMap::new(),
}
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn from_map(name: impl Into<String>, map: HashMap<String, String>) -> Self {
Self {
name: name.into(),
values: map,
}
}
}
impl ConfigSource for MapSource {
fn name(&self) -> &str {
&self.name
}
fn get_raw(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
fn all(&self) -> HashMap<String, String> {
self.values.clone()
}
}
/// Typed config value extractor.
pub trait FromConfigStr: Sized {
fn from_config_str(s: &str) -> Result<Self, ConfigError>;
}
impl FromConfigStr for String {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
Ok(s.to_string())
}
}
impl FromConfigStr for u32 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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");
}
}
-18
View File
@@ -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]
-299
View File
@@ -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<String, String>),
}
/// A bundle of translations for a single locale.
#[derive(Debug, Clone)]
pub struct LocaleBundle {
pub locale: Locale,
translations: HashMap<String, TranslationValue>,
}
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<String>, value: impl Into<String>) {
self.translations.insert(
key.into(),
TranslationValue::Simple(value.into()),
);
}
/// Insert a plural translation.
pub fn insert_plural(
&mut self,
key: impl Into<String>,
forms: HashMap<String, String>,
) {
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<String> {
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<String> {
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<LocaleBundle, String> {
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());
}
}
-221
View File
@@ -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<char> = 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" => ("", 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%");
}
}
-44
View File
@@ -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::*;
-182
View File
@@ -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<String>,
}
impl Locale {
/// Create from a BCP 47 tag like "en-US" or "ar".
pub fn new(tag: impl Into<String>) -> 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");
}
}
-189
View File
@@ -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. 24 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);
}
}
-209
View File
@@ -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<LocaleBundle>,
}
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");
}
}
-22
View File
@@ -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]
-96
View File
@@ -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<Role>,
/// All scopes granted across all roles (via grants edges).
pub scopes: Vec<Scope>,
}
impl IdentityContext {
pub fn new(user: User, session: Session, roles: Vec<Role>, scopes: Vec<Scope>) -> 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<String> {
self.roles.iter().map(|r| r.name.clone()).collect()
}
/// All scope names as strings.
pub fn scope_names(&self) -> Vec<String> {
self.scopes.iter().map(|s| s.name.clone()).collect()
}
}
-214
View File
@@ -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<Option<serde_json::Value>, 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<String, IdentityError>;
/// 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<Vec<serde_json::Value>, 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<Vec<serde_json::Value>, 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<id, NodeEntry>` and edges in a `Vec<Edge>`.
/// Thread-safe via `RwLock`.
#[derive(Debug, Default)]
pub struct MockEngramClient {
nodes: RwLock<HashMap<String, NodeEntry>>,
edges: RwLock<Vec<Edge>>,
}
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<Option<serde_json::Value>, 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<String, IdentityError> {
// 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<Vec<serde_json::Value>, 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<Vec<serde_json::Value>, IdentityError> {
let edges = self.edges.read().expect("edges lock poisoned");
let to_ids: Vec<String> = 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<dyn EngramClient> {
Arc::new(MockEngramClient::new())
}
-56
View File
@@ -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<T> = Result<T, IdentityError>;
-152
View File
@@ -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<dyn EngramClient>,
session_manager: Arc<SessionManager>,
}
impl AuthGuard {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> 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<IdentityContext> {
// 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> = role_nodes
.iter()
.filter_map(Role::from_value)
.collect();
// 4. Load scopes via grants edges from each role
let mut scopes: Vec<Scope> = 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()))
}
}
-37
View File
@@ -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;
-244
View File
@@ -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<Utc>,
}
impl User {
/// Create a new user with a fresh UUID.
pub fn new(email: impl Into<String>, display_name: impl Into<String>) -> 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<Self> {
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<String>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(perm.into());
self
}
pub fn with_permissions(mut self, perms: impl IntoIterator<Item = impl Into<String>>) -> 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<Self> {
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<String>, description: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
description: description.into(),
}
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
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<String>,
pub expires_at: DateTime<Utc>,
/// Scopes granted by this token.
pub scopes: Vec<String>,
}
impl OAuthToken {
pub fn new(
provider: impl Into<String>,
access_token_hash: impl Into<String>,
refresh_token_hash: Option<String>,
expires_at: DateTime<Utc>,
scopes: Vec<String>,
) -> 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<Self> {
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<Utc>,
pub expires_at: DateTime<Utc>,
/// The IP address that created this session.
pub ip_address: Option<String>,
}
impl Session {
pub fn new(user_id: Uuid, ttl_seconds: i64, ip_address: Option<String>) -> 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<Self> {
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";
-275
View File
@@ -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<String>,
/// Expiry in seconds from now.
pub expires_in: u64,
pub scopes: Vec<String>,
}
/// OAuth flow coordinator — executes auth code + PKCE flows and writes
/// the resulting tokens to the Engram graph.
pub struct OAuthFlow {
client: Arc<dyn EngramClient>,
/// Kept for future use (e.g., session creation during OAuth callback).
_session_manager: Arc<SessionManager>,
}
impl OAuthFlow {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> 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<AuthFlowParams> {
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<OAuthToken> {
// 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<OAuthToken> {
// 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)
}
}
-372
View File
@@ -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<TokenSet>;
/// Refresh an access token using a refresh token.
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet>;
/// 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<serde_json::Value> {
// 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<TokenSet> {
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<String>, client_secret: impl Into<String>) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
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, &params)?;
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<String>,
client_secret_jwt: impl Into<String>,
team_id: impl Into<String>,
) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
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, &params)?;
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<String>, client_secret: impl Into<String>) -> 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<TokenSet> {
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, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
// 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
}
-120
View File
@@ -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<dyn EngramClient>,
/// Default session TTL in seconds (default: 3600 = 1 hour).
pub default_ttl_seconds: i64,
}
impl SessionManager {
pub fn new(client: Arc<dyn EngramClient>) -> 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<String>,
) -> IdentityResult<Session> {
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<Session> {
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<Vec<Session>> {
let nodes = self
.client
.find_connected(user_id, EDGE_HAS_SESSION)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
let sessions: Vec<Session> = 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<usize> {
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)
}
}
-563
View File
@@ -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<MockEngramClient> {
Arc::new(MockEngramClient::new())
}
fn make_session_manager(client: Arc<MockEngramClient>) -> Arc<SessionManager> {
Arc::new(SessionManager::new(client as Arc<dyn crate::EngramClient>))
}
fn setup_auth_guard() -> (Arc<MockEngramClient>, Arc<SessionManager>, AuthGuard) {
let client = make_client();
let sm = make_session_manager(client.clone());
let guard = AuthGuard::new(
client.clone() as Arc<dyn crate::EngramClient>,
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<dyn crate::EngramClient>).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");
}
}
-16
View File
@@ -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]
-98
View File
@@ -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());
}
}
}
-162
View File
@@ -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());
}
}
-178
View File
@@ -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);
}
}
-187
View File
@@ -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);
}
}
-50
View File
@@ -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<u32> = 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::*;
-197
View File
@@ -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);
}
}
-154
View File
@@ -1,154 +0,0 @@
/// Responsive<T> — 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<T> for layout — VStack and Grid handle
/// reflow automatically. Use Responsive<T> 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<T: Clone> {
/// Mobile-first base value. Always required.
pub base: T,
/// 640dp+. If None, uses `base`.
pub sm: Option<T>,
/// 768dp+. If None, uses `sm` or `base`.
pub md: Option<T>,
/// 1024dp+. If None, uses `md`, `sm`, or `base`.
pub lg: Option<T>,
/// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`.
pub xl: Option<T>,
}
impl<T: Clone> Responsive<T> {
/// 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<T>,
md: Option<T>,
lg: Option<T>,
xl: Option<T>,
) -> 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");
}
}
-141
View File
@@ -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);
}
}
-286
View File
@@ -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);
}
}
-15
View File
@@ -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]
-169
View File
@@ -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<PlatformNode> {
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<PlatformNode> {
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<String> {
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(())
}
}
-166
View File
@@ -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<PlatformNode> {
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<PlatformNode> {
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<String> {
// 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(())
}
}
-155
View File
@@ -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<PlatformNode> {
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<PlatformNode> {
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<String> {
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(())
}
}
-137
View File
@@ -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<PlatformNode> {
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<PlatformNode> {
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<String> {
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(())
}
}
-12
View File
@@ -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;
-160
View File
@@ -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<String> {
let body = self.render_to_string(node)?;
Ok(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<div id="app" data-el-ssr="true">
{body}
</div>
<script type="module" src="{runtime_script}"></script>
</body>
</html>"#
))
}
}
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<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
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<String> {
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
}
}
-253
View File
@@ -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("<div"), "should contain div tag");
assert!(html.contains("container"), "should contain class name");
assert!(html.contains("Hello"), "should contain text content");
assert!(html.contains("</div>"), "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("<!DOCTYPE html>"), "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("<ul>"));
assert!(html.contains("<li>item 1</li>"));
assert!(html.contains("<li>item 2</li>"));
assert!(html.contains("</ul>"));
}
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
#[test]
fn test_html_escaping() {
let node = PlatformNode::element("span")
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
let html = node.to_html();
assert!(!html.contains("<script>"), "should escape <script>");
assert!(html.contains("&lt;script&gt;"), "should contain escaped form");
}
// ── Test 11: Void elements render correctly ───────────────────────────────
#[test]
fn test_void_elements() {
let node = PlatformNode::element("input").with_attr("type", "text");
let html = node.to_html();
assert!(html.contains("<input"), "should have input");
assert!(!html.contains("</input>"), "void element should not have closing tag");
assert!(html.contains("/>"), "should self-close");
}
// ── Test 12: Fragment node renders children inline ────────────────────────
#[test]
fn test_fragment_node() {
let frag = PlatformNode::fragment()
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
let html = frag.to_html();
assert!(html.contains("<span>A</span>"));
assert!(html.contains("<span>B</span>"));
// No wrapping element
assert!(!html.starts_with('<') || html.starts_with("<span>"));
}
// ── Test 13: WebBackend remove_child works ───────────────────────────────
#[test]
fn test_web_backend_remove_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("first"))
.unwrap();
backend
.append_child(&mut parent, PlatformNode::text("second"))
.unwrap();
assert_eq!(parent.children.len(), 2);
backend.remove_child(&mut parent, 0).unwrap();
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text_content(), Some("second"));
}
// ── Test 14: WebBackend replace_child works ──────────────────────────────
#[test]
fn test_web_backend_replace_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("old"))
.unwrap();
backend
.replace_child(&mut parent, 0, PlatformNode::text("new"))
.unwrap();
assert_eq!(parent.children[0].text_content(), Some("new"));
}
// ── Test 15: backend_for() returns correct backend names ─────────────────
#[test]
fn test_backend_for_names() {
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
}
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
#[test]
fn test_web_backend_bind_event() {
let backend = WebBackend::new();
let mut btn = backend.create_element("button").unwrap();
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
assert!(has_attr, "should emit data-el-click attribute");
}
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
#[test]
fn test_server_backend_bind_event() {
let backend = ServerBackend::new();
let mut btn = PlatformNode::element("button");
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
assert!(has_attr, "should emit hydration marker for click event");
}
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
#[test]
fn test_web_backend_supports_ssr() {
// Web backend supports ssr (renders to HTML for hydration)
let backend = WebBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 19: remove_child out of bounds returns error ────────────────────
#[test]
fn test_remove_child_out_of_bounds() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
let result = backend.remove_child(&mut parent, 5);
assert!(result.is_err(), "out-of-bounds remove should return error");
}
// ── Test 20: All native backends render to HTML for testing ──────────────
#[test]
fn test_native_backends_render_to_html() {
// Native backends support render_to_string for testing and SSR fallback.
let targets = [
PlatformTarget::Ios,
PlatformTarget::Android,
PlatformTarget::Macos,
PlatformTarget::Linux,
PlatformTarget::Windows,
];
for target in &targets {
let backend = backend_for(target);
let node = PlatformNode::element("div")
.with_child(PlatformNode::text("test"));
let html = backend.render_to_string(&node).unwrap();
assert!(
html.contains("test"),
"{} backend should render text content",
target.as_str()
);
}
}
}
-134
View File
@@ -1,134 +0,0 @@
//! Web backend — DOM rendering in browsers.
//!
//! This backend formalizes what `runtime/src/renderer.js` does, as a Rust
//! description of the DOM patching strategy. In a WASM build, this would call
//! into the browser's DOM API directly via `web-sys`.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
/// Web DOM backend.
///
/// Renders component trees to the browser DOM. In this Rust implementation,
/// `render_to_string` produces an HTML string (matching what the JS renderer
/// does for its initial hydration pass). A full WASM build would instead
/// call `document.createElement()` etc. via `web-sys`.
pub struct WebBackend;
impl WebBackend {
pub fn new() -> Self {
Self
}
}
impl Default for WebBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WebBackend {
fn name(&self) -> &'static str {
"web"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::text(content))
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// Remove existing attribute with same name then push new one.
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!(
"web: child index {} out of bounds (len {})",
child_index,
parent.children.len()
)));
}
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!(
"web: 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<()> {
// In a real WASM build: node.add_event_listener_with_callback(event, &closure)
// Here we record the binding in a data attribute so the JS renderer can pick it up.
node.attributes
.push(crate::Attribute::new(format!("data-el-{}", event), "[bound]"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// In a real WASM build:
// let container = document.query_selector(container_id)?;
// container.set_inner_html(&root.to_html());
// For the Rust-side representation, mount is a no-op.
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// Full DOM patching mirrors renderer.js patch():
// 1. Walk old and new trees in parallel.
// 2. For each position: if node kinds differ, replace; else update attributes.
// 3. Recurse into children.
// In a WASM build this calls web-sys DOM mutation APIs.
Ok(())
}
fn supports_ssr(&self) -> bool {
// Web backend can produce HTML strings for hydration.
true
}
}
-156
View File
@@ -1,156 +0,0 @@
//! Windows backend — Win32/WinUI.
//!
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a HWND or WinUI control:
//! element("div") → Panel (WinUI StackPanel)
//! element("button") → Button (WinUI)
//! element("input") → TextBox (WinUI)
//! text("...") → TextBlock (WinUI)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct WindowsBackend;
impl WindowsBackend {
pub fn new() -> Self {
Self
}
fn winui_control(tag: &str) -> &'static str {
match tag {
"button" => "Microsoft.UI.Xaml.Controls.Button",
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
"img" => "Microsoft.UI.Xaml.Controls.Image",
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
}
}
fn winui_event(event: &str) -> &'static str {
match event {
"click" => "Click",
"input" | "change" => "TextChanged",
"focus" => "GotFocus",
"blur" => "LostFocus",
"keydown" | "keypress" => "KeyDown",
"keyup" => "KeyUp",
_ => "PointerPressed",
}
}
}
impl Default for WindowsBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WindowsBackend {
fn name(&self) -> &'static str {
"windows"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: WinRT/COM activation via windows-rs crate:
// let panel: StackPanel = StackPanel::new()?;
node.attributes
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: TextBlock::new()?.set_text(content)
node.attributes
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to WinUI 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: panel.Children().Append(child_element)
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!(
"windows: child index {} out of bounds",
child_index
)));
}
// TODO: panel.Children().RemoveAt(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(
"windows: 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 winui_event = Self::winui_event(event);
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
node.attributes.push(crate::Attribute::new(
format!("data-winui-event-{}", event),
winui_event,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Window by container_id, set root as Content
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply WinUI mutations
Ok(())
}
}
-82
View File
@@ -1,82 +0,0 @@
//! Platform configuration — parsed from `manifest.el`.
/// Which platform to target for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformTarget {
/// Web: DOM rendering in browsers.
Web,
/// Server: SSR — render to HTML string, served by axum.
Server,
/// iOS: UIKit via C FFI / ObjC bridge.
Ios,
/// Android: NDK + JNI bridge.
Android,
/// macOS: AppKit bindings.
Macos,
/// Linux: GTK/Wayland.
Linux,
/// Windows: Win32/WinUI.
Windows,
}
impl PlatformTarget {
/// Parse from the string value used in `manifest.el`.
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"web" => Some(Self::Web),
"server" => Some(Self::Server),
"ios" => Some(Self::Ios),
"android" => Some(Self::Android),
"macos" => Some(Self::Macos),
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
_ => None,
}
}
/// The canonical string name for this target.
pub fn as_str(&self) -> &'static str {
match self {
Self::Web => "web",
Self::Server => "server",
Self::Ios => "ios",
Self::Android => "android",
Self::Macos => "macos",
Self::Linux => "linux",
Self::Windows => "windows",
}
}
/// Whether this target is a native (non-web, non-server) platform.
pub fn is_native(&self) -> bool {
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
}
}
/// Full platform configuration, reflecting the `[platform]` section of `manifest.el`.
#[derive(Debug, Clone)]
pub struct PlatformConfig {
pub target: PlatformTarget,
/// Enable server-side rendering fallback.
pub ssr: bool,
}
impl Default for PlatformConfig {
fn default() -> Self {
Self {
target: PlatformTarget::Web,
ssr: false,
}
}
}
impl PlatformConfig {
pub fn new(target: PlatformTarget) -> Self {
Self { target, ssr: false }
}
pub fn with_ssr(mut self, ssr: bool) -> Self {
self.ssr = ssr;
self
}
}
-126
View File
@@ -1,126 +0,0 @@
//! el-platform — Universal rendering backends for el-ui.
//!
//! The same component code produces native output for every target platform.
//! No bridge. No virtual DOM. Direct platform calls.
//!
//! The target is chosen in `manifest.el`:
//!
//! ```toml
//! [platform]
//! target = "web" # web | server | ios | android | macos | linux | windows
//! ssr = true
//! ```
//!
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
//! is correct and complete now.
pub mod backends;
pub mod config;
pub mod node;
pub use backends::{
android::AndroidBackend,
ios::IosBackend,
linux::LinuxBackend,
macos::MacosBackend,
server::ServerBackend,
web::WebBackend,
windows::WindowsBackend,
};
pub use config::{PlatformConfig, PlatformTarget};
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlatformError {
#[error("render error: {0}")]
Render(String),
#[error("mount error: {0}")]
Mount(String),
#[error("unsupported operation on target {target}: {op}")]
Unsupported { target: String, op: String },
#[error("event binding error: {0}")]
EventBinding(String),
}
pub type PlatformResult<T> = Result<T, PlatformError>;
/// The core trait every platform backend must implement.
///
/// All rendering paths go through this interface. Component code is identical
/// across targets — only the backend chosen by `manifest.el` differs.
pub trait PlatformBackend: Send + Sync {
/// The platform name (e.g. "web", "server", "ios").
fn name(&self) -> &'static str;
/// Create a new element node on this platform.
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
/// Create a text node on this platform.
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
/// Set an attribute on a node.
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
-> PlatformResult<()>;
/// Remove an attribute from a node.
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
/// Append a child node to a parent.
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
-> PlatformResult<()>;
/// Remove a child node from a parent.
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
/// Replace a child node at the given index.
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()>;
/// Bind an event handler to a node.
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
handler: EventHandler,
) -> PlatformResult<()>;
/// Render a node tree to its platform representation.
/// For `server`, this returns an HTML string.
/// For `web`, this patches the live DOM.
/// For native targets, this calls the appropriate native APIs.
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
/// Mount a node tree into the platform's root container.
/// `container_id` is a platform-specific identifier (CSS selector for web,
/// view controller ID for iOS, activity ID for Android, etc.).
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
/// Patch an existing mounted tree with a new tree.
/// The backend performs the minimal update needed.
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
/// Whether this backend supports SSR (rendering to HTML string on the server).
fn supports_ssr(&self) -> bool {
false
}
}
/// Select the backend for a given platform target.
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
match target {
PlatformTarget::Web => Box::new(WebBackend::new()),
PlatformTarget::Server => Box::new(ServerBackend::new()),
PlatformTarget::Ios => Box::new(IosBackend::new()),
PlatformTarget::Android => Box::new(AndroidBackend::new()),
PlatformTarget::Macos => Box::new(MacosBackend::new()),
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
}
}
-158
View File
@@ -1,158 +0,0 @@
//! Platform-agnostic node tree.
//!
//! `PlatformNode` is the universal representation of a UI element.
//! Each backend converts this to its native equivalent.
/// An attribute on a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: String,
}
impl Attribute {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: value.into() }
}
}
/// A boxed event handler function.
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
/// knowing the native event type.
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
/// The kind of a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformNodeKind {
/// An element node: `<div>`, `<button>`, etc.
Element { tag: String },
/// A plain text node.
Text { content: String },
/// A component boundary marker (used for patch reconciliation).
Component { name: String },
/// A fragment — groups children without a wrapper element.
Fragment,
}
/// A platform-agnostic UI node.
///
/// This is the universal intermediate representation. Each backend renders
/// `PlatformNode` trees to its native format.
#[derive(Debug)]
pub struct PlatformNode {
pub kind: PlatformNodeKind,
pub attributes: Vec<Attribute>,
pub children: Vec<PlatformNode>,
/// Opaque platform handle — the backend stores its native pointer/reference
/// here after mounting. `None` before mount.
pub native_handle: Option<usize>,
}
impl PlatformNode {
/// Create an element node.
pub fn element(tag: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Element { tag: tag.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a text node.
pub fn text(content: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Text { content: content.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a fragment node.
pub fn fragment() -> Self {
Self {
kind: PlatformNodeKind::Fragment,
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Add an attribute.
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.push(Attribute::new(name, value));
self
}
/// Add a child node.
pub fn with_child(mut self, child: PlatformNode) -> Self {
self.children.push(child);
self
}
/// Get the tag name if this is an element node.
pub fn tag(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Element { tag } => Some(tag),
_ => None,
}
}
/// Get the text content if this is a text node.
pub fn text_content(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Text { content } => Some(content),
_ => None,
}
}
/// Render the node tree to an HTML string.
/// This is used by the server backend and for testing all backends.
pub fn to_html(&self) -> String {
match &self.kind {
PlatformNodeKind::Text { content } => html_escape(content),
PlatformNodeKind::Fragment => {
self.children.iter().map(|c| c.to_html()).collect()
}
PlatformNodeKind::Component { name } => {
format!("<!-- component:{} -->", name)
}
PlatformNodeKind::Element { tag } => {
let mut out = format!("<{}", tag);
for attr in &self.attributes {
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
}
// Void elements — no closing tag
if is_void_element(tag) {
out.push_str(" />");
return out;
}
out.push('>');
for child in &self.children {
out.push_str(&child.to_html());
}
out.push_str(&format!("</{}>", tag));
out
}
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn html_escape_attr(s: &str) -> String {
html_escape(s).replace('"', "&quot;")
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag,
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
| "link" | "meta" | "param" | "source" | "track" | "wbr"
)
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "el-publish"
version = "0.1.0"
edition = "2021"
description = "el-ui app publishing pipeline — one command ships to every platform"
license = "MIT"
[lib]
name = "el_publish"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
-126
View File
@@ -1,126 +0,0 @@
//! Apple App Store Connect API publisher.
//!
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
//! correct and complete. A future agent fills in the `reqwest` calls.
//!
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
use crate::{
config::{AppleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app builds to Apple App Store Connect / TestFlight.
pub struct ApplePublisher {
pub apple_config: AppleConfig,
pub publish_config: PublishConfig,
/// API key for App Store Connect (loaded from env or keychain).
#[allow(dead_code)]
api_key: Option<String>,
}
impl ApplePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let apple_config = publish_config.apple.clone().ok_or_else(|| {
PublishError::Config("no [publish.apple] section in manifest.el".into())
})?;
Ok(Self {
apple_config,
publish_config,
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
})
}
/// Upload a build to TestFlight.
///
/// In production, this calls the App Store Connect API:
/// POST /v1/builds
/// PUT /v1/builds/{id}/betaAppReviewDetail
/// POST /v1/betaTestersConfigurations
pub fn upload_to_testflight(
&self,
ipa_path: &str,
) -> PublishResult<String> {
// TODO: use reqwest to call App Store Connect API:
// 1. Authenticate with JWT from API key
// 2. POST /v1/builds with IPA binary
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
// 4. Submit to TestFlight review
let _ = ipa_path;
let submission_id = format!(
"TF-{}-{}",
self.apple_config.bundle_id,
self.publish_config.build_number
);
Ok(submission_id)
}
/// Submit to App Store review.
///
/// In production:
/// POST /v1/appStoreVersionSubmissions
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
pub fn submit_to_app_store(
&self,
build_id: &str,
metadata: &StoreMetadata,
) -> PublishResult<String> {
// TODO: actual API calls
let _ = (build_id, metadata);
let review_id = format!("AS-{}", self.publish_config.build_number);
Ok(review_id)
}
/// Set staged rollout percentage on App Store.
/// Only available after the initial release.
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
let _ = percent;
Ok(())
}
/// Full publish flow: build → upload → submit.
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let build_id = self.upload_to_testflight(ipa_path)?;
let track = if to_beta { "testflight" } else { "app_store" };
if !to_beta {
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
let _ = review_id;
}
if let Some(rollout) = &self.publish_config.rollout {
self.set_phased_release(rollout.initial_percent)?;
}
Ok(PublishOutcome::new("apple", &self.publish_config, track)
.with_submission_id(build_id))
}
/// List existing builds from App Store Connect.
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
// TODO: GET /v1/builds?filter[bundleId]=...
Ok(vec![BuildInfo {
id: format!("build-{}", self.publish_config.build_number),
version: self.publish_config.version.clone(),
status: "READY_FOR_DISTRIBUTION".into(),
}])
}
}
/// Info about a build in App Store Connect.
#[derive(Debug, Clone)]
pub struct BuildInfo {
pub id: String,
pub version: String,
pub status: String,
}
-157
View File
@@ -1,157 +0,0 @@
//! Certificate management — load, save, check expiry, generate renewal warnings.
use crate::{PublishError, PublishResult};
use std::time::{SystemTime, UNIX_EPOCH};
/// Info about a code signing certificate.
#[derive(Debug, Clone)]
pub struct CertInfo {
pub name: String,
pub team_id: String,
pub serial: String,
/// Certificate type: "Distribution", "Development", "Push", etc.
pub cert_type: String,
/// Expiry as Unix timestamp (seconds since epoch).
pub expires_at: u64,
/// Whether the certificate is currently valid.
pub is_valid: bool,
}
impl CertInfo {
pub fn new(
name: impl Into<String>,
team_id: impl Into<String>,
serial: impl Into<String>,
cert_type: impl Into<String>,
expires_at: u64,
) -> Self {
let now = unix_now();
Self {
name: name.into(),
team_id: team_id.into(),
serial: serial.into(),
cert_type: cert_type.into(),
expires_at,
is_valid: expires_at > now,
}
}
/// Days until expiry (0 if already expired).
pub fn days_until_expiry(&self) -> u64 {
let now = unix_now();
if self.expires_at <= now {
return 0;
}
(self.expires_at - now) / 86400
}
/// Whether the cert expires within `days` days.
pub fn expires_soon(&self, days: u64) -> bool {
self.days_until_expiry() <= days
}
pub fn is_expired(&self) -> bool {
unix_now() >= self.expires_at
}
}
/// Certificate store — loads, caches, and checks expiry of code signing certs.
pub struct CertStore {
certs: Vec<CertInfo>,
/// How many days before expiry to warn (default: 30).
pub warn_days: u64,
}
impl CertStore {
pub fn new() -> Self {
Self { certs: Vec::new(), warn_days: 30 }
}
pub fn with_warn_days(mut self, days: u64) -> Self {
self.warn_days = days;
self
}
/// Add a certificate to the store.
pub fn add(&mut self, cert: CertInfo) {
self.certs.push(cert);
}
/// Find a certificate by team ID and type.
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
self.certs.iter().find(|c| {
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
})
}
/// Get all certificates expiring soon (within `warn_days`).
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
self.certs
.iter()
.filter(|c| c.expires_soon(self.warn_days))
.collect()
}
/// Generate renewal warnings for expiring certificates.
pub fn renewal_warnings(&self) -> Vec<String> {
self.expiring_soon()
.iter()
.map(|c| {
if c.is_expired() {
format!(
"EXPIRED: {} ({}) — team {}. Renew immediately.",
c.name, c.cert_type, c.team_id
)
} else {
format!(
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
c.name, c.cert_type, c.days_until_expiry(), c.team_id
)
}
})
.collect()
}
/// Check that a valid distribution certificate exists for the given team.
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
let cert = self.find(team_id, "Distribution");
match cert {
None => Err(PublishError::Certificate(format!(
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
team_id
))),
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
"Distribution certificate for team {} expires in {} days. Renew now.",
team_id,
c.days_until_expiry()
))),
_ => Ok(()),
}
}
/// Load certificates from a JSON file (stub — real impl would parse
/// Apple's certificate PEM files or keychain API).
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
Ok(Self::new())
}
/// Save certificate metadata to a JSON cache file.
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
// TODO: serialize cert metadata to JSON
Ok(())
}
}
impl Default for CertStore {
fn default() -> Self {
Self::new()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
-144
View File
@@ -1,144 +0,0 @@
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
/// App Store Connect / TestFlight configuration.
#[derive(Debug, Clone)]
pub struct AppleConfig {
pub account: String,
pub team_id: String,
pub bundle_id: String,
pub category: String,
}
impl AppleConfig {
pub fn new(
account: impl Into<String>,
team_id: impl Into<String>,
bundle_id: impl Into<String>,
) -> Self {
Self {
account: account.into(),
team_id: team_id.into(),
bundle_id: bundle_id.into(),
category: "productivity".into(),
}
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
}
/// Google Play Developer API configuration.
#[derive(Debug, Clone)]
pub struct GoogleConfig {
pub service_account_path: String,
pub package_name: String,
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
pub track: String,
}
impl GoogleConfig {
pub fn new(
service_account_path: impl Into<String>,
package_name: impl Into<String>,
) -> Self {
Self {
service_account_path: service_account_path.into(),
package_name: package_name.into(),
track: "internal".into(),
}
}
pub fn with_track(mut self, track: impl Into<String>) -> Self {
self.track = track.into();
self
}
}
/// Staged rollout configuration.
#[derive(Debug, Clone)]
pub struct RolloutConfig {
/// Initial rollout percentage (0-100).
pub initial_percent: u8,
/// Automatically advance rollout after `advance_after_hours` hours.
pub auto_advance: bool,
pub advance_after_hours: u64,
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
pub max_crash_rate: f64,
}
impl Default for RolloutConfig {
fn default() -> Self {
Self {
initial_percent: 100,
auto_advance: false,
advance_after_hours: 24,
max_crash_rate: 0.01,
}
}
}
impl RolloutConfig {
pub fn new(initial_percent: u8) -> Self {
Self { initial_percent, ..Default::default() }
}
pub fn with_auto_advance(mut self, hours: u64) -> Self {
self.auto_advance = true;
self.advance_after_hours = hours;
self
}
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
self.max_crash_rate = rate;
self
}
}
/// The complete publish configuration.
#[derive(Debug, Clone)]
pub struct PublishConfig {
pub version: String,
pub build_number: u32,
pub apple: Option<AppleConfig>,
pub google: Option<GoogleConfig>,
pub rollout: Option<RolloutConfig>,
/// Directory with store metadata (title.txt, description.txt, etc.)
pub metadata_dir: String,
pub screenshot_targets: Vec<String>,
}
impl PublishConfig {
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
Self {
version: version.into(),
build_number,
apple: None,
google: None,
rollout: None,
metadata_dir: "./store".into(),
screenshot_targets: Vec::new(),
}
}
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
self.apple = Some(apple);
self
}
pub fn with_google(mut self, google: GoogleConfig) -> Self {
self.google = Some(google);
self
}
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
self.rollout = Some(rollout);
self
}
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
self
}
}
-124
View File
@@ -1,124 +0,0 @@
//! Google Play Developer API publisher.
//!
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
//!
//! API reference: https://developers.google.com/android-publisher
use crate::{
config::{GoogleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app bundles to Google Play Store.
pub struct GooglePublisher {
pub google_config: GoogleConfig,
pub publish_config: PublishConfig,
}
impl GooglePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let google_config = publish_config.google.clone().ok_or_else(|| {
PublishError::Config("no [publish.google] section in manifest.el".into())
})?;
Ok(Self { google_config, publish_config })
}
/// Create a new edit session on Google Play.
///
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
pub fn create_edit(&self) -> PublishResult<String> {
// TODO: OAuth2 authentication via service account JSON
let edit_id = format!("edit-{}", self.publish_config.build_number);
Ok(edit_id)
}
/// Upload an AAB (Android App Bundle) to an edit session.
///
/// In production: POST .../edits/{editId}/bundles (multipart upload)
pub fn upload_bundle(
&self,
edit_id: &str,
aab_path: &str,
) -> PublishResult<u32> {
let _ = (edit_id, aab_path);
// Returns the version code of the uploaded bundle
Ok(self.publish_config.build_number)
}
/// Assign a bundle to a track.
///
/// In production: PUT .../edits/{editId}/tracks/{track}
pub fn assign_to_track(
&self,
edit_id: &str,
version_code: u32,
track: &str,
rollout_fraction: f64,
) -> PublishResult<()> {
let _ = (edit_id, version_code, track, rollout_fraction);
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
return Err(PublishError::Config(format!(
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
track
)));
}
Ok(())
}
/// Upload store listing (metadata) for a locale.
///
/// In production: PATCH .../edits/{editId}/listings/{language}
pub fn upload_listing(
&self,
edit_id: &str,
locale: &str,
metadata: &StoreMetadata,
) -> PublishResult<()> {
let _ = (edit_id, locale, metadata);
Ok(())
}
/// Commit an edit (makes the changes live).
///
/// In production: POST .../edits/{editId}:commit
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
// Returns the resulting version code
Ok(format!("{}-committed", edit_id))
}
/// Update rollout percentage for a track (for staged rollouts).
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
let _ = (track, percent);
// TODO: PATCH .../tracks/{track} with rollout fraction
Ok(())
}
/// Full publish flow: create edit → upload bundle → assign to track → commit.
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let edit_id = self.create_edit()?;
let version_code = self.upload_bundle(&edit_id, aab_path)?;
let track = &self.google_config.track;
let rollout_fraction = self
.publish_config
.rollout
.as_ref()
.map(|r| r.initial_percent as f64 / 100.0)
.unwrap_or(1.0);
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
self.upload_listing(&edit_id, "en-US", &metadata)?;
let commit_id = self.commit_edit(&edit_id)?;
Ok(PublishOutcome::new("google", &self.publish_config, track)
.with_submission_id(commit_id))
}
}
-98
View File
@@ -1,98 +0,0 @@
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
//!
//! One command ships to every platform:
//! ```bash
//! el publish # all platforms
//! el publish --apple # App Store only
//! el publish --google # Play Store only
//! el publish --beta # TestFlight + Play internal track
//! ```
//!
//! Configuration in `manifest.el`:
//! ```toml
//! [publish]
//! version = "1.0.0"
//! build_number = 42
//!
//! [publish.apple]
//! account = "will@neurontechnologies.ai"
//! bundle_id = "ai.neurontechnologies.myapp"
//!
//! [publish.google]
//! package = "ai.neurontechnologies.myapp"
//! track = "internal"
//! ```
pub mod apple;
pub mod cert;
pub mod config;
pub mod google;
pub mod metadata;
pub mod rollout;
pub mod screenshot;
pub use apple::ApplePublisher;
pub use cert::{CertInfo, CertStore};
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
pub use google::GooglePublisher;
pub use metadata::StoreMetadata;
pub use rollout::RolloutMonitor;
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PublishError {
#[error("config error: {0}")]
Config(String),
#[error("build error: {0}")]
Build(String),
#[error("upload error: {0}")]
Upload(String),
#[error("certificate error: {0}")]
Certificate(String),
#[error("metadata error: {0}")]
Metadata(String),
#[error("api error: {status} {body}")]
Api { status: u16, body: String },
#[error("io error: {0}")]
Io(String),
}
pub type PublishResult<T> = Result<T, PublishError>;
/// The outcome of a publish operation.
#[derive(Debug, Clone)]
pub struct PublishOutcome {
pub platform: String,
pub version: String,
pub build_number: u32,
pub track: String,
pub rollout_percent: u8,
pub submission_id: Option<String>,
}
impl PublishOutcome {
pub fn new(
platform: impl Into<String>,
config: &PublishConfig,
track: impl Into<String>,
) -> Self {
Self {
platform: platform.into(),
version: config.version.clone(),
build_number: config.build_number,
track: track.into(),
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
submission_id: None,
}
}
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
self.submission_id = Some(id.into());
self
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
//!
//! Directory structure (mirrors Fastlane's metadata format):
//! ```text
//! store/
//! en-US/
//! title.txt
//! description.txt
//! whats-new.txt
//! keywords.txt
//! promotional-text.txt
//! de-DE/
//! title.txt
//! ...
//! ```
use crate::{PublishError, PublishResult};
use std::collections::HashMap;
use std::path::Path;
/// Metadata for a single locale.
#[derive(Debug, Clone, Default)]
pub struct LocaleMetadata {
pub title: String,
pub description: String,
pub whats_new: String,
pub keywords: Vec<String>,
pub promotional_text: String,
}
impl LocaleMetadata {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
title: title.into(),
description: description.into(),
..Default::default()
}
}
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
self.whats_new = whats_new.into();
self
}
}
/// Complete store metadata across all locales.
#[derive(Debug, Clone, Default)]
pub struct StoreMetadata {
pub locales: HashMap<String, LocaleMetadata>,
}
impl StoreMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
self.locales.insert(locale.into(), metadata);
self
}
/// Get metadata for a specific locale, falling back to `en-US`.
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
}
/// Load metadata from the directory structure.
///
/// Falls back gracefully: if the metadata directory doesn't exist,
/// returns empty metadata (don't fail the publish for missing store copy).
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
let path = Path::new(dir);
if !path.exists() {
// Warn but don't fail — metadata is optional for internal/alpha builds.
return Ok(Self::new());
}
let mut metadata = Self::new();
let read_dir = std::fs::read_dir(path).map_err(|e| {
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
})?;
for entry in read_dir.flatten() {
let locale_path = entry.path();
if !locale_path.is_dir() {
continue;
}
let locale = locale_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if locale.is_empty() {
continue;
}
let locale_meta = LocaleMetadata {
title: read_file(&locale_path, "title.txt"),
description: read_file(&locale_path, "description.txt"),
whats_new: read_file(&locale_path, "whats-new.txt"),
keywords: read_file(&locale_path, "keywords.txt")
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect(),
promotional_text: read_file(&locale_path, "promotional-text.txt"),
};
metadata.locales.insert(locale, locale_meta);
}
Ok(metadata)
}
}
fn read_file(dir: &Path, filename: &str) -> String {
std::fs::read_to_string(dir.join(filename))
.unwrap_or_default()
.trim()
.to_string()
}
-131
View File
@@ -1,131 +0,0 @@
//! Rollout monitor — checks crash rate, advances or halts rollout.
use crate::{config::RolloutConfig, PublishError, PublishResult};
/// Current rollout state.
#[derive(Debug, Clone)]
pub struct RolloutState {
pub track: String,
pub current_percent: u8,
pub crash_rate: f64,
pub is_halted: bool,
pub hours_elapsed: u64,
}
impl RolloutState {
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
Self {
track: track.into(),
current_percent: initial_percent,
crash_rate: 0.0,
is_halted: false,
hours_elapsed: 0,
}
}
}
/// Monitors a staged rollout and decides whether to advance or halt.
pub struct RolloutMonitor {
pub config: RolloutConfig,
}
impl RolloutMonitor {
pub fn new(config: RolloutConfig) -> Self {
Self { config }
}
/// Evaluate whether to advance, halt, or maintain the current rollout.
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
if state.is_halted {
return RolloutDecision::Halted {
reason: "rollout was previously halted".into(),
};
}
// Check crash rate
if state.crash_rate > self.config.max_crash_rate {
return RolloutDecision::Halt {
reason: format!(
"crash rate {:.2}% exceeds threshold {:.2}%",
state.crash_rate * 100.0,
self.config.max_crash_rate * 100.0
),
};
}
// Check if we should advance
if self.config.auto_advance
&& state.hours_elapsed >= self.config.advance_after_hours
&& state.current_percent < 100
{
let next_percent = advance_percent(state.current_percent);
return RolloutDecision::Advance { to_percent: next_percent };
}
RolloutDecision::Maintain
}
/// Apply a rollout decision — returns the new rollout state.
pub fn apply(
&self,
mut state: RolloutState,
decision: &RolloutDecision,
) -> PublishResult<RolloutState> {
match decision {
RolloutDecision::Advance { to_percent } => {
state.current_percent = *to_percent;
state.hours_elapsed = 0;
}
RolloutDecision::Halt { reason } => {
state.is_halted = true;
let _ = reason;
}
RolloutDecision::Halted { .. } => {
return Err(PublishError::Config(
"cannot apply decision to halted rollout".into(),
));
}
RolloutDecision::Maintain => {}
}
Ok(state)
}
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
///
/// In production: fetch crash rate from Firebase Crashlytics or App Store
/// Connect Analytics API.
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
// TODO: fetch real crash rate from analytics API
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
// state.crash_rate = crash_rate;
state.hours_elapsed += 1;
let decision = self.evaluate(&state);
let new_state = self.apply(state, &decision)?;
Ok((new_state, decision))
}
}
/// The outcome of a rollout evaluation.
#[derive(Debug, Clone)]
pub enum RolloutDecision {
/// Advance to a higher rollout percentage.
Advance { to_percent: u8 },
/// Halt the rollout due to high crash rate.
Halt { reason: String },
/// Rollout was already halted.
Halted { reason: String },
/// No change needed.
Maintain,
}
/// Determine the next rollout percentage.
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
fn advance_percent(current: u8) -> u8 {
match current {
0..=9 => 10,
10..=24 => 25,
25..=49 => 50,
_ => 100,
}
}
-135
View File
@@ -1,135 +0,0 @@
//! Screenshot pipeline — captures app screenshots for each store target size.
use crate::PublishResult;
/// A screenshot target device/size specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotTarget {
/// Display name (e.g. "iPhone 6.7\"")
pub name: String,
/// Target identifier used in manifest.el (e.g. "iphone-6.7")
pub id: String,
pub width: u32,
pub height: u32,
pub platform: ScreenshotPlatform,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenshotPlatform {
Ios,
Android,
}
impl ScreenshotTarget {
/// All standard screenshot targets, matching Apple and Google requirements.
pub fn all_standard() -> Vec<Self> {
vec![
Self {
name: "iPhone 6.7\"".into(),
id: "iphone-6.7".into(),
width: 1290,
height: 2796,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPhone 6.1\"".into(),
id: "iphone-6.1".into(),
width: 1179,
height: 2556,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPad 13\"".into(),
id: "ipad-13".into(),
width: 2064,
height: 2752,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "Android Phone".into(),
id: "android-phone".into(),
width: 1080,
height: 1920,
platform: ScreenshotPlatform::Android,
},
]
}
/// Find a target by its ID.
pub fn find(id: &str) -> Option<Self> {
Self::all_standard().into_iter().find(|t| t.id == id)
}
}
/// A captured screenshot.
#[derive(Debug, Clone)]
pub struct Screenshot {
pub target: ScreenshotTarget,
pub locale: String,
pub scenario: String,
/// Path to the PNG file.
pub path: String,
}
/// Trait for screenshot capture backends.
///
/// Implementations might use:
/// - `xcrun simctl` for iOS Simulator screenshots
/// - Android Emulator ADB screenshots
/// - Puppeteer/Playwright for web screenshots
pub trait ScreenshotCapture: Send + Sync {
/// Capture a screenshot for the given scenario and target.
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot>;
/// Capture all scenarios for all targets and locales.
fn capture_all(
&self,
scenarios: &[String],
targets: &[ScreenshotTarget],
locales: &[String],
output_dir: &str,
) -> PublishResult<Vec<Screenshot>> {
let mut screenshots = Vec::new();
for scenario in scenarios {
for target in targets {
for locale in locales {
let shot = self.capture(scenario, target, locale, output_dir)?;
screenshots.push(shot);
}
}
}
Ok(screenshots)
}
}
/// Stub screenshot capture (produces placeholder paths without actually
/// launching a simulator). A future agent fills in the real capture logic.
pub struct StubScreenshotCapture;
impl ScreenshotCapture for StubScreenshotCapture {
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot> {
let filename = format!(
"{}/{}/{}-{}.png",
output_dir, locale, target.id, scenario
);
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
Ok(Screenshot {
target: target.clone(),
locale: locale.to_string(),
scenario: scenario.to_string(),
path: filename,
})
}
}
-264
View File
@@ -1,264 +0,0 @@
//! Tests for el-publish.
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
cert::{CertInfo, CertStore},
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
metadata::{LocaleMetadata, StoreMetadata},
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
PublishError,
};
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn test_config() -> PublishConfig {
PublishConfig::new("1.2.0", 42)
.with_apple(AppleConfig::new(
"will@example.com",
"TEAM123",
"ai.example.myapp",
))
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
}
// ── Test 1: PublishConfig parses version and build number ─────────────────
#[test]
fn test_publish_config_basic() {
let cfg = test_config();
assert_eq!(cfg.version, "1.2.0");
assert_eq!(cfg.build_number, 42);
assert!(cfg.apple.is_some());
assert!(cfg.google.is_some());
}
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
#[test]
fn test_apple_config() {
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
.with_category("utilities");
assert_eq!(cfg.account, "will@example.com");
assert_eq!(cfg.team_id, "TEAM123");
assert_eq!(cfg.category, "utilities");
}
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
#[test]
fn test_google_config_default_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
assert_eq!(cfg.track, "internal");
}
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
#[test]
fn test_google_config_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
.with_track("production");
assert_eq!(cfg.track, "production");
}
// ── Test 5: RolloutConfig default values ─────────────────────────────────
#[test]
fn test_rollout_config_default() {
let cfg = RolloutConfig::default();
assert_eq!(cfg.initial_percent, 100);
assert!(!cfg.auto_advance);
assert_eq!(cfg.max_crash_rate, 0.01);
}
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
#[test]
fn test_rollout_maintain() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001, // well below threshold
is_halted: false,
hours_elapsed: 5, // less than 24h advance threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Maintain));
}
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
#[test]
fn test_rollout_halt_on_crash_rate() {
let cfg = RolloutConfig::new(10);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.05, // 5% > 1% threshold
is_halted: false,
hours_elapsed: 2,
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Halt { .. }));
}
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
#[test]
fn test_rollout_advance() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 25, // exceeds 24h threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
}
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
#[test]
fn test_rollout_advance_progression() {
let cfg = RolloutConfig::new(10).with_auto_advance(1);
let monitor = RolloutMonitor::new(cfg);
let mut state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 5,
};
let expected = [25u8, 50, 100];
for expected_next in expected {
let decision = monitor.evaluate(&state);
if let RolloutDecision::Advance { to_percent } = decision {
state.current_percent = to_percent;
state.hours_elapsed = 5;
assert_eq!(to_percent, expected_next);
} else {
panic!("expected Advance decision, got {:?}", decision);
}
}
}
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
#[test]
fn test_cert_days_until_expiry() {
let future = unix_now() + 30 * 86400; // 30 days from now
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
let days = cert.days_until_expiry();
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
}
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
#[test]
fn test_cert_is_expired() {
let past = unix_now() - 86400; // yesterday
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
assert!(cert.is_expired());
assert_eq!(cert.days_until_expiry(), 0);
}
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
#[test]
fn test_cert_store_renewal_warnings() {
let mut store = CertStore::new().with_warn_days(45);
let soon = unix_now() + 10 * 86400; // 10 days away
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
let warnings = store.renewal_warnings();
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
}
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
#[test]
fn test_cert_store_validate_no_cert() {
let store = CertStore::new();
let result = store.validate_for_distribution("TEAM123");
assert!(result.is_err());
match result {
Err(PublishError::Certificate(_)) => {}
_ => panic!("expected Certificate error"),
}
}
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
#[test]
fn test_store_metadata_locale_fallback() {
let meta = StoreMetadata::new()
.add_locale(
"en-US",
LocaleMetadata::new("My App", "The best app ever"),
);
// de-DE not set, should fall back to en-US
let locale_meta = meta.for_locale("de-DE").unwrap();
assert_eq!(locale_meta.title, "My App");
}
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
#[test]
fn test_store_metadata_missing_dir() {
// Missing directory should not error — returns empty metadata
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
assert!(result.is_ok());
}
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
#[test]
fn test_screenshot_targets() {
let targets = ScreenshotTarget::all_standard();
assert!(targets.len() >= 4, "should have at least 4 standard targets");
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"iphone-6.7"));
assert!(ids.contains(&"android-phone"));
}
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
#[test]
fn test_screenshot_target_find() {
let target = ScreenshotTarget::find("ipad-13").unwrap();
assert_eq!(target.name, "iPad 13\"");
assert_eq!(target.width, 2064);
}
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
#[test]
fn test_stub_screenshot_capture() {
let capture = StubScreenshotCapture;
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
assert!(shot.path.contains("iphone-6.7"));
assert!(shot.path.contains("home_screen"));
assert_eq!(shot.locale, "en-US");
}
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
#[test]
fn test_publish_config_with_rollout() {
let rollout = RolloutConfig::new(10)
.with_auto_advance(24)
.with_max_crash_rate(0.005);
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
let r = cfg.rollout.unwrap();
assert_eq!(r.initial_percent, 10);
assert!(r.auto_advance);
assert_eq!(r.max_crash_rate, 0.005);
}
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
#[test]
fn test_publish_config_screenshot_targets() {
let cfg = PublishConfig::new("1.0.0", 1)
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
assert_eq!(cfg.screenshot_targets.len(), 2);
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
}
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "el-secrets"
version = "0.1.0"
edition = "2021"
description = "el-ui secrets management — typed, never-logged, source-agnostic"
license = "MIT"
[lib]
name = "el_secrets"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
-19
View File
@@ -1,19 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecretsError {
#[error("secret '{key}' not found in source '{source_name}'")]
NotFound { key: String, source_name: String },
#[error("secret '{key}' not found in any configured source")]
NotFoundInAnySource { key: String },
#[error("secrets source '{source_name}' unavailable: {reason}")]
SourceUnavailable { source_name: String, reason: String },
#[error("required secrets missing at startup: {keys:?}")]
MissingRequired { keys: Vec<String> },
#[error("secret type conversion failed for key '{key}': {reason}")]
TypeError { key: String, reason: String },
}
-51
View File
@@ -1,51 +0,0 @@
//! el-secrets — Secrets management for el-ui applications.
//!
//! Secrets are NEVER in code. They are:
//! - `EnvVarSource` — environment variables (`EL_SECRET_JWT_KEY=...`)
//! - `VaultSource` — HashiCorp Vault (stub, ready for HTTP client integration)
//! - `AwsSecretsSource` — AWS Secrets Manager (stub)
//! - `InMemorySource` — for testing only
//!
//! All secrets are wrapped in `Secret<T>` which:
//! - Displays as `[REDACTED]` in all logs and debug output
//! - Serializes as `"[REDACTED]"` in JSON — never the actual value
//! - Requires explicit `.expose()` to read
//!
//! ## Quick start
//!
//! ```
//! use el_secrets::prelude::*;
//!
//! // Load secrets at startup — fails early if anything is missing
//! let mut src = InMemorySource::new();
//! src.insert("jwt.key", "test-key-for-testing-only");
//!
//! let secrets = SecretsResolver::new()
//! .source(Box::new(src))
//! .require("jwt.key")
//! .resolve()
//! .expect("required secrets must be present at startup");
//!
//! let jwt_key = secrets.require("jwt.key");
//! // jwt_key displays as [REDACTED]
//! // jwt_key.expose() gives the actual value
//! assert_eq!(jwt_key.expose(), "test-key-for-testing-only");
//! ```
#![deny(warnings)]
pub mod error;
pub mod resolver;
pub mod secret;
pub mod source;
pub mod prelude {
pub use crate::error::SecretsError;
pub use crate::resolver::{ResolvedSecrets, SecretsResolver};
pub use crate::secret::{Secret, SecretGuard};
pub use crate::source::{
AwsSecretsSource, EnvVarSource, InMemorySource, SecretsSource, VaultSource,
};
}
pub use prelude::*;
-282
View File
@@ -1,282 +0,0 @@
/// SecretsResolver — loads all required secrets at startup.
///
/// The resolver validates that all required secrets are present before the
/// application starts. If any are missing, startup fails with a clear error
/// listing what's missing — not a runtime panic deep in the app.
use std::collections::HashMap;
use crate::error::SecretsError;
use crate::secret::Secret;
use crate::source::SecretsSource;
/// A resolved, validated set of secrets.
///
/// Created by SecretsResolver at startup. Once resolved, all secrets are
/// available and guaranteed to have been present at startup time.
pub struct ResolvedSecrets {
values: HashMap<String, Secret<String>>,
}
impl ResolvedSecrets {
/// Get a secret by key.
///
/// Returns None if the key wasn't declared as required.
/// In normal use you will always have the keys you declared.
pub fn get(&self, key: &str) -> Option<&Secret<String>> {
self.values.get(key)
}
/// Get a secret or panic with a clear message.
///
/// Use this for secrets that are truly required and were declared in
/// the resolver — if the resolver passed, this will always succeed.
pub fn require(&self, key: &str) -> &Secret<String> {
self.values.get(key).unwrap_or_else(|| {
panic!(
"Secret '{}' was not declared as required in SecretsResolver. \
Declare all required secrets before calling resolve().",
key
)
})
}
/// The number of resolved secrets.
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
/// Loads and validates all required secrets at startup.
///
/// Usage:
/// ```ignore
/// let secrets = SecretsResolver::new()
/// .source(EnvVarSource::new())
/// .require("jwt.secret_key")
/// .require("database.password")
/// .resolve()?;
///
/// let jwt_key = secrets.require("jwt.secret_key");
/// ```
pub struct SecretsResolver {
sources: Vec<Box<dyn SecretsSource>>,
required: Vec<String>,
}
impl SecretsResolver {
pub fn new() -> Self {
Self {
sources: Vec::new(),
required: Vec::new(),
}
}
/// Add a secret source. Sources are tried in order; first success wins.
pub fn source(mut self, source: Box<dyn SecretsSource>) -> Self {
self.sources.push(source);
self
}
/// Declare a required secret key.
///
/// All required keys must be present in at least one source.
/// resolve() fails if any are missing.
pub fn require(mut self, key: impl Into<String>) -> Self {
self.required.push(key.into());
self
}
/// Declare multiple required secret keys.
pub fn require_all(mut self, keys: &[&str]) -> Self {
for key in keys {
self.required.push(key.to_string());
}
self
}
/// Load all required secrets and validate they are all present.
///
/// Returns an error listing ALL missing secrets — not just the first one —
/// so you can fix them all in one go.
pub fn resolve(self) -> Result<ResolvedSecrets, SecretsError> {
let mut values = HashMap::new();
let mut missing = Vec::new();
for key in &self.required {
match self.fetch_from_sources(key) {
Ok(secret) => {
values.insert(key.clone(), secret);
}
Err(_) => {
missing.push(key.clone());
}
}
}
if !missing.is_empty() {
return Err(SecretsError::MissingRequired { keys: missing });
}
Ok(ResolvedSecrets { values })
}
/// Attempt to fetch a key from sources in order.
fn fetch_from_sources(&self, key: &str) -> Result<Secret<String>, SecretsError> {
for source in &self.sources {
if let Ok(secret) = source.get(key) {
return Ok(secret);
}
}
Err(SecretsError::NotFoundInAnySource {
key: key.to_string(),
})
}
/// Resolve without requiring all keys — useful when you want
/// to load whatever is available.
pub fn resolve_optional(self) -> ResolvedSecrets {
let mut values = HashMap::new();
for key in &self.required {
if let Ok(secret) = self.fetch_from_sources(key) {
values.insert(key.clone(), secret);
}
}
ResolvedSecrets { values }
}
}
impl Default for SecretsResolver {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::InMemorySource;
fn make_source(pairs: &[(&str, &str)]) -> InMemorySource {
let mut src = InMemorySource::new();
for (k, v) in pairs {
src.insert(*k, *v);
}
src
}
#[test]
fn resolve_all_present() {
let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("db.password")
.resolve()
.unwrap();
assert_eq!(secrets.require("jwt.key").expose(), "secret");
assert_eq!(secrets.require("db.password").expose(), "pass123");
}
#[test]
fn resolve_missing_fails_with_list() {
let src = make_source(&[("jwt.key", "secret")]);
let result = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("db.password") // missing
.require("stripe.key") // also missing
.resolve();
match result {
Err(SecretsError::MissingRequired { keys }) => {
assert!(keys.contains(&"db.password".to_string()));
assert!(keys.contains(&"stripe.key".to_string()));
assert_eq!(keys.len(), 2);
}
_ => panic!("expected MissingRequired error"),
}
}
#[test]
fn resolve_optional_skips_missing() {
let src = make_source(&[("jwt.key", "secret")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("missing.key")
.resolve_optional();
assert!(secrets.get("jwt.key").is_some());
assert!(secrets.get("missing.key").is_none());
}
#[test]
fn multiple_sources_fallback() {
let mut primary = InMemorySource::new();
primary.insert("jwt.key", "from-primary");
let mut secondary = InMemorySource::new();
secondary.insert("db.password", "from-secondary");
let secrets = SecretsResolver::new()
.source(Box::new(primary))
.source(Box::new(secondary))
.require("jwt.key")
.require("db.password")
.resolve()
.unwrap();
assert_eq!(secrets.require("jwt.key").expose(), "from-primary");
assert_eq!(secrets.require("db.password").expose(), "from-secondary");
}
#[test]
fn first_source_wins() {
let mut s1 = InMemorySource::new();
s1.insert("key", "value-1");
let mut s2 = InMemorySource::new();
s2.insert("key", "value-2");
let secrets = SecretsResolver::new()
.source(Box::new(s1))
.source(Box::new(s2))
.require("key")
.resolve()
.unwrap();
assert_eq!(secrets.require("key").expose(), "value-1");
}
#[test]
fn require_all() {
let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require_all(&["a", "b", "c"])
.resolve()
.unwrap();
assert_eq!(secrets.len(), 3);
}
#[test]
fn resolved_len() {
let src = make_source(&[("k", "v")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("k")
.resolve()
.unwrap();
assert_eq!(secrets.len(), 1);
}
#[test]
fn resolved_is_empty() {
let resolved = ResolvedSecrets { values: HashMap::new() };
assert!(resolved.is_empty());
}
}
-179
View File
@@ -1,179 +0,0 @@
/// Secret<T> — a typed value that never leaks via Display/Debug.
///
/// The wrapper ensures that accidental logging or serialization of a secret
/// never reveals the actual value. You must explicitly call `.expose()` to
/// read it, which creates a visible opt-in point in the code.
use serde::{Deserialize, Serialize, Serializer};
/// A secret value. Never prints the inner value via Display or Debug.
///
/// Always displays as `[REDACTED]`. To access the inner value:
/// ```
/// use el_secrets::Secret;
/// let key = Secret::new("my-secret-key".to_string());
/// let actual: &str = key.expose(); // explicit opt-in
/// ```
#[derive(Clone)]
pub struct Secret<T: Clone>(T);
impl<T: Clone> Secret<T> {
/// Wrap a value in a Secret.
pub fn new(value: T) -> Self {
Self(value)
}
/// Access the inner value.
///
/// This is the ONLY way to get the actual secret value out.
/// Name it `expose` so it's searchable in code review.
pub fn expose(&self) -> &T {
&self.0
}
/// Consume the Secret and return the inner value.
pub fn into_inner(self) -> T {
self.0
}
/// Map the inner value to a new type, wrapping in a new Secret.
pub fn map<U: Clone, F: FnOnce(T) -> U>(self, f: F) -> Secret<U> {
Secret(f(self.0))
}
}
/// Debug never reveals the secret value.
impl<T: Clone> std::fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Secret([REDACTED])")
}
}
/// Display never reveals the secret value.
impl<T: Clone> std::fmt::Display for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
/// Serialize writes [REDACTED], never the actual value.
/// This prevents secrets from appearing in JSON logs, API responses, etc.
impl<T: Clone> Serialize for Secret<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("[REDACTED]")
}
}
/// Deserialize from a string — used when loading secrets from files/env.
/// Only implemented for Secret<String> since we always load as strings.
impl<'de> Deserialize<'de> for Secret<String> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Secret::new(s))
}
}
/// A guard that prevents a value from being accidentally exposed.
///
/// Use this on struct fields that should never be serialized or logged.
#[derive(Clone)]
pub struct SecretGuard<T: Clone> {
inner: Secret<T>,
/// A hint shown in Debug output (not the value itself).
label: &'static str,
}
impl<T: Clone> SecretGuard<T> {
pub fn new(value: T, label: &'static str) -> Self {
Self {
inner: Secret::new(value),
label,
}
}
pub fn expose(&self) -> &T {
self.inner.expose()
}
}
impl<T: Clone> std::fmt::Debug for SecretGuard<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SecretGuard({}: [REDACTED])", self.label)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_expose() {
let s = Secret::new("my-api-key".to_string());
assert_eq!(s.expose(), "my-api-key");
}
#[test]
fn secret_debug_redacted() {
let s = Secret::new("super-secret".to_string());
let debug = format!("{:?}", s);
assert_eq!(debug, "Secret([REDACTED])");
assert!(!debug.contains("super-secret"));
}
#[test]
fn secret_display_redacted() {
let s = Secret::new(12345u32);
let display = format!("{}", s);
assert_eq!(display, "[REDACTED]");
assert!(!display.contains("12345"));
}
#[test]
fn secret_serialize_redacted() {
let s = Secret::new("should-not-appear".to_string());
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#""[REDACTED]""#);
assert!(!json.contains("should-not-appear"));
}
#[test]
fn secret_deserialize() {
let s: Secret<String> = serde_json::from_str(r#""my-secret""#).unwrap();
assert_eq!(s.expose(), "my-secret");
}
#[test]
fn secret_map() {
let s = Secret::new("42".to_string());
let n: Secret<u32> = s.map(|v| v.parse().unwrap());
assert_eq!(*n.expose(), 42u32);
}
#[test]
fn secret_into_inner() {
let s = Secret::new("value".to_string());
assert_eq!(s.into_inner(), "value");
}
#[test]
fn secret_guard_debug() {
let g = SecretGuard::new("token".to_string(), "jwt_token");
let debug = format!("{:?}", g);
assert!(debug.contains("jwt_token"));
assert!(!debug.contains("token\""));
}
#[test]
fn secret_guard_expose() {
let g = SecretGuard::new("secret-value".to_string(), "api_key");
assert_eq!(g.expose(), "secret-value");
}
#[test]
fn secret_clone_does_not_expose() {
let s1 = Secret::new("clone-me".to_string());
let s2 = s1.clone();
assert_eq!(s2.expose(), "clone-me");
assert!(!format!("{:?}", s2).contains("clone-me"));
}
}
-251
View File
@@ -1,251 +0,0 @@
/// SecretsSource trait and built-in implementations.
///
/// Each source knows how to retrieve a named secret. Sources are tried in
/// order by the SecretsResolver until one succeeds.
use crate::error::SecretsError;
use crate::secret::Secret;
/// A source of secret values.
pub trait SecretsSource: Send + Sync {
/// The name of this source.
fn name(&self) -> &str;
/// Retrieve a secret by key.
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError>;
/// List available secret keys (may not be supported by all sources).
fn list(&self) -> Result<Vec<String>, SecretsError>;
}
/// Reads secrets from environment variables.
///
/// Key mapping: `jwt.key` → `EL_SECRET_JWT_KEY` (uppercased, dots → underscores).
pub struct EnvVarSource {
prefix: String,
}
impl EnvVarSource {
pub fn new() -> Self {
Self { prefix: "EL_SECRET".to_string() }
}
pub fn with_prefix(prefix: impl Into<String>) -> 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 SecretsSource for EnvVarSource {
fn name(&self) -> &str {
"env-var"
}
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
let env_key = self.env_key(key);
std::env::var(&env_key)
.map(Secret::new)
.map_err(|_| SecretsError::NotFound {
key: key.to_string(),
source_name: "env-var".to_string(),
})
}
fn list(&self) -> Result<Vec<String>, SecretsError> {
let prefix = format!("{}_", self.prefix);
let keys: Vec<String> = std::env::vars()
.filter(|(k, _)| k.starts_with(&prefix))
.map(|(k, _)| {
k.strip_prefix(&prefix)
.unwrap_or(&k)
.to_lowercase()
.replace('_', ".")
})
.collect();
Ok(keys)
}
}
/// In-memory secrets source for testing.
///
/// NEVER use in production — secrets are in plaintext in memory
/// and this source is not safe for production credentials.
#[derive(Default)]
pub struct InMemorySource {
secrets: std::collections::HashMap<String, String>,
}
impl InMemorySource {
pub fn new() -> Self {
Self::default()
}
/// Insert a secret. Only for testing.
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.secrets.insert(key.into(), value.into());
}
}
impl SecretsSource for InMemorySource {
fn name(&self) -> &str {
"in-memory"
}
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
self.secrets
.get(key)
.map(|v| Secret::new(v.clone()))
.ok_or_else(|| SecretsError::NotFound {
key: key.to_string(),
source_name: "in-memory".to_string(),
})
}
fn list(&self) -> Result<Vec<String>, SecretsError> {
Ok(self.secrets.keys().cloned().collect())
}
}
/// Vault source stub — HashiCorp Vault integration.
///
/// This is a stub that defines the interface. A real implementation
/// would make HTTP calls to the Vault API. The stub is sufficient
/// for the type system and resolver to work correctly.
pub struct VaultSource {
/// Vault server URL (e.g. "https://vault.example.com").
pub address: String,
/// Mount path for the KV engine (e.g. "secret").
pub mount: String,
/// The Vault path prefix for this app's secrets.
pub path: String,
}
impl VaultSource {
pub fn new(address: impl Into<String>, mount: impl Into<String>, path: impl Into<String>) -> Self {
Self {
address: address.into(),
mount: mount.into(),
path: path.into(),
}
}
}
impl SecretsSource for VaultSource {
fn name(&self) -> &str {
"vault"
}
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
// Stub: in a real impl, make HTTP GET to Vault KV API
Err(SecretsError::SourceUnavailable {
source_name: "vault".to_string(),
reason: format!(
"Vault HTTP client not implemented in stub — would fetch {}/{}/{}/{}",
self.address, self.mount, self.path, key
),
})
}
fn list(&self) -> Result<Vec<String>, SecretsError> {
Err(SecretsError::SourceUnavailable {
source_name: "vault".to_string(),
reason: "Vault HTTP client not implemented in stub".to_string(),
})
}
}
/// AWS Secrets Manager source stub.
pub struct AwsSecretsSource {
pub region: String,
pub path_prefix: String,
}
impl AwsSecretsSource {
pub fn new(region: impl Into<String>, path_prefix: impl Into<String>) -> Self {
Self {
region: region.into(),
path_prefix: path_prefix.into(),
}
}
}
impl SecretsSource for AwsSecretsSource {
fn name(&self) -> &str {
"aws-secrets-manager"
}
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
Err(SecretsError::SourceUnavailable {
source_name: "aws-secrets-manager".to_string(),
reason: format!(
"AWS SDK not linked — would fetch {}/{} in {}",
self.path_prefix, key, self.region
),
})
}
fn list(&self) -> Result<Vec<String>, SecretsError> {
Err(SecretsError::SourceUnavailable {
source_name: "aws-secrets-manager".to_string(),
reason: "AWS SDK not linked".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_get() {
let mut src = InMemorySource::new();
src.insert("jwt.key", "secret-jwt-key");
let val = src.get("jwt.key").unwrap();
assert_eq!(val.expose(), "secret-jwt-key");
}
#[test]
fn in_memory_missing() {
let src = InMemorySource::new();
assert!(src.get("missing.key").is_err());
}
#[test]
fn in_memory_list() {
let mut src = InMemorySource::new();
src.insert("key.a", "a");
src.insert("key.b", "b");
let mut keys = src.list().unwrap();
keys.sort();
assert_eq!(keys, vec!["key.a", "key.b"]);
}
#[test]
fn env_key_mapping() {
let src = EnvVarSource::new();
assert_eq!(src.env_key("jwt.key"), "EL_SECRET_JWT_KEY");
assert_eq!(src.env_key("database.password"), "EL_SECRET_DATABASE_PASSWORD");
}
#[test]
fn vault_source_stub_returns_error() {
let src = VaultSource::new("https://vault.example.com", "secret", "myapp");
assert!(src.get("jwt.key").is_err());
}
#[test]
fn aws_source_stub_returns_error() {
let src = AwsSecretsSource::new("us-east-1", "myapp");
assert!(src.get("jwt.key").is_err());
}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "el-services"
version = "0.1.0"
edition = "2021"
description = "el-ui service bindings — write once, bind to any protocol"
license = "MIT"
[lib]
name = "el_services"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
-84
View File
@@ -1,84 +0,0 @@
//! Direct binding — server-side only; calls Rust functions directly, zero network.
//!
//! Used when the component and the service implementation run in the same
//! process. Eliminates all serialization/deserialization overhead.
//!
//! The handler registry maps `"ServiceName::method_name"` to a function pointer.
//! The proxy calls the function directly.
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{ServiceError, ServiceResult};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// A direct handler function — takes params, returns a JSON body string.
pub type DirectHandler = Arc<dyn Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync>;
/// Direct binding — calls registered Rust functions without any network hop.
pub struct DirectBinding {
/// Registry: `"ServiceName::method_name"` → handler fn
handlers: Arc<RwLock<HashMap<String, DirectHandler>>>,
}
impl DirectBinding {
pub fn new() -> Self {
Self {
handlers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Register a handler for a service method.
///
/// ```
/// # use el_services::binding::direct::DirectBinding;
/// let binding = DirectBinding::new();
/// binding.register("UserService", "get_user", |params| {
/// let id = params.get("id").map(|s| s.as_str()).unwrap_or("unknown");
/// Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
/// });
/// ```
pub fn register(
&self,
service: &str,
method: &str,
handler: impl Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync + 'static,
) {
let key = format!("{}::{}", service, method);
self.handlers
.write()
.expect("lock poisoned")
.insert(key, Arc::new(handler));
}
/// Check if a handler is registered.
pub fn has_handler(&self, service: &str, method: &str) -> bool {
let key = format!("{}::{}", service, method);
self.handlers
.read()
.expect("lock poisoned")
.contains_key(&key)
}
}
impl Default for DirectBinding {
fn default() -> Self {
Self::new()
}
}
impl Binding for DirectBinding {
fn kind(&self) -> BindingKind {
BindingKind::Direct
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
let key = format!("{}::{}", request.service, request.method);
let handlers = self.handlers.read().expect("lock poisoned");
let handler = handlers.get(&key).ok_or_else(|| ServiceError::MethodNotFound {
service: request.service.clone(),
method: request.method.clone(),
})?;
let body = handler(request.params)?;
Ok(ServiceResponse::ok(body))
}
}
-97
View File
@@ -1,97 +0,0 @@
//! gRPC binding — protobuf over HTTP/2.
//!
//! The architecture is complete. The actual codegen that takes a service
//! definition and generates `.proto` files + Tonic client stubs is a TODO
//! for a future agent.
//!
//! What is implemented:
//! - `GrpcBinding` struct and `Binding` trait impl
//! - Method name → gRPC endpoint mapping (`/package.ServiceName/MethodName`)
//! - Stub call that produces the expected response format
//!
//! What needs a future agent:
//! - `.proto` file generation from service definition AST
//! - `tonic::transport::Channel` setup
//! - Actual RPC call via generated Tonic client
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
/// gRPC binding.
///
/// Uses the gRPC naming convention:
/// `/<package>.<ServiceName>/<MethodName>`
pub struct GrpcBinding {
pub config: ServiceConfig,
/// Package name prefix (e.g. "myapp.v1"). Defaults to empty.
pub package: String,
}
impl GrpcBinding {
pub fn new(config: ServiceConfig) -> Self {
Self { config, package: String::new() }
}
pub fn with_package(mut self, package: impl Into<String>) -> Self {
self.package = package.into();
self
}
/// Build the gRPC endpoint path for a method call.
/// `/package.ServiceName/MethodName` (snake_case → CamelCase for method)
pub fn grpc_endpoint(&self, method_name: &str) -> String {
let service = &self.config.name;
let method = snake_to_camel(method_name);
if self.package.is_empty() {
format!("/{}/{}", service, method)
} else {
format!("/{}.{}/{}", self.package, service, method)
}
}
}
fn snake_to_camel(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
impl Binding for GrpcBinding {
fn kind(&self) -> BindingKind {
BindingKind::Grpc
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
if self.config.base_url.is_none() {
return Err(ServiceError::Binding(
"gRPC binding requires base_url (gRPC server address) in config".into(),
));
}
let _endpoint = self.grpc_endpoint(&request.method);
// TODO (future agent): generate .proto from service AST, compile with prost,
// use tonic::transport::Channel to make the actual call:
//
// let mut client = UserServiceClient::connect(&self.config.base_url).await?;
// let request = tonic::Request::new(GetUserRequest { id: params["id"].clone() });
// let response = client.get_user(request).await?;
// return Ok(ServiceResponse::ok(serde_json::to_string(&response.into_inner())?));
Ok(ServiceResponse::ok(format!(
"{{\"grpc\":true,\"service\":\"{}\",\"method\":\"{}\"}}",
request.service, request.method
)))
}
fn supports_streaming(&self) -> bool {
// gRPC natively supports server streaming, client streaming, and bidi.
true
}
}
-114
View File
@@ -1,114 +0,0 @@
//! Service binding implementations.
//!
//! Each binding protocol implements the `Binding` trait.
//! The proxy uses whatever binding is configured in `manifest.el`.
pub mod direct;
pub mod grpc;
pub mod rest;
pub mod websocket;
use crate::ServiceResult;
use std::collections::HashMap;
/// Which protocol this service binding uses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingKind {
/// HTTP REST — maps service methods to HTTP endpoints.
Rest,
/// WebSocket — persistent connection, message-based RPC.
WebSocket,
/// gRPC — protobuf over HTTP/2 (stub; codegen is a future TODO).
Grpc,
/// Direct — server-side only; calls Rust functions directly, zero network.
Direct,
}
impl BindingKind {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"rest" => Some(Self::Rest),
"websocket" | "ws" => Some(Self::WebSocket),
"grpc" => Some(Self::Grpc),
"direct" => Some(Self::Direct),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Rest => "rest",
Self::WebSocket => "websocket",
Self::Grpc => "grpc",
Self::Direct => "direct",
}
}
}
/// A service call request — method name and parameters.
#[derive(Debug, Clone)]
pub struct ServiceRequest {
pub service: String,
pub method: String,
/// Parameters as key-value pairs. Complex types are JSON-serialized strings.
pub params: HashMap<String, String>,
}
impl ServiceRequest {
pub fn new(service: impl Into<String>, method: impl Into<String>) -> Self {
Self {
service: service.into(),
method: method.into(),
params: HashMap::new(),
}
}
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.insert(key.into(), value.into());
self
}
}
/// A service call response — raw body string (JSON, protobuf bytes as hex, etc.)
#[derive(Debug, Clone)]
pub struct ServiceResponse {
pub status: u16,
pub body: String,
pub headers: HashMap<String, String>,
}
impl ServiceResponse {
pub fn ok(body: impl Into<String>) -> Self {
Self {
status: 200,
body: body.into(),
headers: HashMap::new(),
}
}
pub fn error(status: u16, body: impl Into<String>) -> Self {
Self {
status,
body: body.into(),
headers: HashMap::new(),
}
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
/// The core binding trait — implemented by each protocol.
pub trait Binding: Send + Sync {
/// The kind of binding this is.
fn kind(&self) -> BindingKind;
/// Execute a service method call and return the response.
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse>;
/// Whether this binding supports streaming responses.
fn supports_streaming(&self) -> bool {
false
}
}
-147
View File
@@ -1,147 +0,0 @@
//! REST binding — maps service methods to HTTP endpoints.
//!
//! Method → endpoint mapping (default convention, overridable):
//! `get_*` → GET /resource/{id}
//! `list_*` → GET /resource
//! `create_*` → POST /resource
//! `update_*` → PUT /resource/{id}
//! `delete_*` → DELETE /resource/{id}
//!
//! In production, use `reqwest` for the HTTP client. This implementation
//! builds the request and returns a mock response so the binding logic
//! can be tested without a live server.
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
/// HTTP REST binding.
pub struct RestBinding {
pub config: ServiceConfig,
}
impl RestBinding {
pub fn new(config: ServiceConfig) -> Self {
Self { config }
}
/// Derive the HTTP method from the service method name.
pub fn http_method(method_name: &str) -> &'static str {
let lower = method_name.to_lowercase();
if lower.starts_with("get_") || lower.starts_with("fetch_") || lower.starts_with("find_") {
"GET"
} else if lower.starts_with("list_") || lower.starts_with("all_") {
"GET"
} else if lower.starts_with("create_") || lower.starts_with("add_") || lower.starts_with("post_") {
"POST"
} else if lower.starts_with("update_") || lower.starts_with("edit_") || lower.starts_with("put_") {
"PUT"
} else if lower.starts_with("delete_") || lower.starts_with("remove_") {
"DELETE"
} else if lower.starts_with("patch_") {
"PATCH"
} else {
"POST"
}
}
/// Derive the URL path from the service name and method name.
/// `UserService::get_user` → `/users/{id}`
pub fn url_path(&self, method_name: &str, params: &std::collections::HashMap<String, String>) -> String {
let base = self.config.base_url.as_deref().unwrap_or("");
let resource = self.resource_name();
let http_method = Self::http_method(method_name);
// Check if there's an ID param
let id_param = params.get("id").or_else(|| {
params.values().next()
});
match (http_method, id_param) {
("GET" | "PUT" | "DELETE", Some(id)) => {
format!("{}/{}/{}", base, resource, id)
}
_ => format!("{}/{}", base, resource),
}
}
/// Derive the REST resource name from the service name.
/// `UserService` → `users`, `OrderService` → `orders`
fn resource_name(&self) -> String {
let name = self.config.name.trim_end_matches("Service");
let lower = name.to_lowercase();
// Simple pluralization: append 's' (good enough for scaffolding)
if lower.ends_with('s') {
lower
} else {
format!("{}s", lower)
}
}
/// Build auth headers for the request.
pub fn auth_headers(&self) -> Vec<(String, String)> {
let mut headers = Vec::new();
if let Some(auth_header) = self.config.auth.authorization_header() {
headers.push(("Authorization".to_string(), auth_header));
}
if let Some((key, val)) = self.config.auth.api_key_header() {
headers.push((key, val));
}
headers
}
/// Build the full request description (for logging/testing without live HTTP).
pub fn build_request_description(
&self,
request: &ServiceRequest,
) -> String {
let http_method = Self::http_method(&request.method);
let url = self.url_path(&request.method, &request.params);
let headers = self.auth_headers();
let body = if matches!(http_method, "POST" | "PUT" | "PATCH") {
serde_params_to_json(&request.params)
} else {
String::new()
};
format!(
"{} {}\nHeaders: {:?}\nBody: {}",
http_method, url, headers, body
)
}
}
/// Minimal JSON serialization for params (no serde dependency).
pub(crate) fn serde_params_to_json(params: &std::collections::HashMap<String, String>) -> String {
let fields: Vec<String> = params
.iter()
.map(|(k, v)| format!("\"{}\":\"{}\"", k, v.replace('"', "\\\"")))
.collect();
format!("{{{}}}", fields.join(","))
}
impl Binding for RestBinding {
fn kind(&self) -> BindingKind {
BindingKind::Rest
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
if self.config.base_url.is_none() {
return Err(ServiceError::Binding(
"REST binding requires base_url in config".into(),
));
}
let _description = self.build_request_description(&request);
// In production: use reqwest to make the actual HTTP call:
// let client = reqwest::blocking::Client::new();
// let resp = client.request(http_method, &url).headers(...).body(body).send()?;
// return Ok(ServiceResponse { status: resp.status().as_u16(), body: resp.text()?, ... });
// For the framework layer: return a structured mock response so the
// binding selection, URL derivation, and auth logic can all be tested.
Ok(ServiceResponse::ok(format!(
"{{\"service\":\"{}\",\"method\":\"{}\"}}",
request.service, request.method
)))
}
}

Some files were not shown because too many files have changed in this diff Show More