Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbf2c659d9 | |||
| 2b8062c55f | |||
| 2ed6b26dde | |||
| d8e9fd12f4 | |||
| 8ef3eb6bec | |||
| 027ad82db2 | |||
| 8fa9c4ba20 | |||
| 8ab8e3fd31 | |||
| 05d717744b | |||
| 9c7bde47dc | |||
| b0d0975f05 | |||
| 6f634ae432 | |||
| c0553459e1 | |||
| 908ce303f3 | |||
| edbde5ef51 | |||
| 2e529bd0fe | |||
| 5d9299a472 | |||
| e8b01583d8 | |||
| fd208583fe | |||
| b19dd5608f | |||
| 94d6eace94 | |||
| 3e29fc43ab | |||
| f1dfc394e3 | |||
| 61bf501b84 | |||
| 979a5677d5 | |||
| 2fd298df55 | |||
| 254cbe0ac2 | |||
| 17b1aa0736 | |||
| bcfb33ea83 | |||
| 60ad7f2f6b | |||
| f0c731d2db | |||
| 231cb5eddd | |||
| 54de7d3f3f | |||
| e7e0f7d3e5 | |||
| 77100649c3 | |||
| b0570656b1 | |||
| a79b421578 | |||
| 0ab9361fab | |||
| f7953eb73a | |||
| 9c350e9f2f | |||
| e93c899d1f | |||
| 0b50e61f98 | |||
| f2741e4bdb | |||
| 7fd01b8a8d | |||
| d2940f5d1d | |||
| dca741f915 | |||
| 9862f4d6e1 | |||
| 8b074d2e39 | |||
| ec9c322cc7 | |||
| 702093e043 | |||
| 95b6fac094 | |||
| af66cebfd3 |
@@ -24,37 +24,41 @@ jobs:
|
||||
apt-get update -qq
|
||||
apt-get install -y gcc libcurl4-openssl-dev
|
||||
|
||||
# Gen2: compile the bootstrap C source into a working elc binary
|
||||
- name: Build elc from bootstrap (gen2)
|
||||
# Seed: use the committed linux-amd64 binary as the bootstrap
|
||||
- name: Bootstrap from committed linux binary (seed)
|
||||
run: |
|
||||
# -Wl,--allow-multiple-definition: elc-bootstrap.c and el_runtime.c both define
|
||||
# is_digit/is_whitespace; bootstrap predates the text-processing primitives commit
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-bootstrap.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-Wl,--allow-multiple-definition \
|
||||
-o dist/elc-gen2
|
||||
chmod +x dist/elc-gen2
|
||||
echo "gen2 elc built"
|
||||
dist/elc-gen2 --version || true
|
||||
chmod +x dist/platform/elc-linux-amd64
|
||||
echo "seed elc (committed linux-amd64 binary)"
|
||||
dist/platform/elc-linux-amd64 --version || true
|
||||
|
||||
# Gen3: use gen2 to compile the El compiler from its own El source (self-host)
|
||||
- name: Self-host compile El compiler with gen2 (gen3)
|
||||
# Gen2: use seed to self-host compile the El compiler
|
||||
- name: Self-host compile El compiler (gen2)
|
||||
run: |
|
||||
mkdir -p dist/platform
|
||||
dist/elc-gen2 el-compiler/src/compiler.el > dist/elc-gen3.c
|
||||
dist/platform/elc-linux-amd64 elc-cli.el > dist/elc-gen2.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-gen3.c \
|
||||
dist/elc-gen2.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/platform/elc
|
||||
chmod +x dist/platform/elc
|
||||
echo "gen3 (self-hosted) elc built"
|
||||
echo "gen2 (self-hosted) elc built"
|
||||
dist/platform/elc --version || true
|
||||
|
||||
# Build elb (needed for Artifact Registry publish and downstream CI)
|
||||
- name: Build elb
|
||||
run: |
|
||||
mkdir -p dist/bin
|
||||
dist/platform/elc elb.el > dist/elb.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elb.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/bin/elb
|
||||
chmod +x dist/bin/elb
|
||||
echo "elb built"
|
||||
|
||||
- name: Run tests - text
|
||||
run: |
|
||||
ELC="$(pwd)/dist/platform/elc" \
|
||||
@@ -87,7 +91,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_core.el > /tmp/el_native_core.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_core.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_core
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_core
|
||||
/tmp/el_native_core
|
||||
|
||||
- name: Run tests - native (text)
|
||||
@@ -97,7 +101,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_text.el > /tmp/el_native_text.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_text.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_text
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_text
|
||||
/tmp/el_native_text
|
||||
|
||||
- name: Run tests - native (string)
|
||||
@@ -107,7 +111,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_string.el > /tmp/el_native_string.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_string.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_string
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_string
|
||||
/tmp/el_native_string
|
||||
|
||||
- name: Run tests - native (math)
|
||||
@@ -117,7 +121,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_math.el > /tmp/el_native_math.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_math.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_math
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_math
|
||||
/tmp/el_native_math
|
||||
|
||||
- name: Run tests - native (state)
|
||||
@@ -127,7 +131,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_state.el > /tmp/el_native_state.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_state.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_state
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_state
|
||||
/tmp/el_native_state
|
||||
|
||||
- name: Run tests - native (time)
|
||||
@@ -137,7 +141,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_time.el > /tmp/el_native_time.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_time.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_time
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_time
|
||||
/tmp/el_native_time
|
||||
|
||||
- name: Run tests - native (json)
|
||||
@@ -147,7 +151,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_json.el > /tmp/el_native_json.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_json.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_json
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_json
|
||||
/tmp/el_native_json
|
||||
|
||||
- name: Run tests - native (env)
|
||||
@@ -157,7 +161,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_env.el > /tmp/el_native_env.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_env.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_env
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_env
|
||||
/tmp/el_native_env
|
||||
|
||||
- name: Run tests - native (fs)
|
||||
@@ -167,9 +171,31 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_fs.el > /tmp/el_native_fs.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_fs.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_fs
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_fs
|
||||
/tmp/el_native_fs
|
||||
|
||||
# Build epm binary using elb (epm lives at repo root, not inside lang/)
|
||||
- name: Build epm
|
||||
run: |
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd ../epm && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/epm
|
||||
echo "epm built"
|
||||
|
||||
# Build el-install binary using elb
|
||||
- name: Build el-install
|
||||
run: |
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd tools/install && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/el-install
|
||||
echo "el-install built"
|
||||
|
||||
# Publish only after merge (push event), not on PR validation runs
|
||||
- name: Publish El SDK to Artifact Registry (dev)
|
||||
if: github.event_name == 'push'
|
||||
@@ -177,20 +203,19 @@ jobs:
|
||||
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 install -y -qq apt-transport-https ca-certificates curl
|
||||
echo "deb [trusted=yes] 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
|
||||
|
||||
VERSION="${GITEA_SHA:0:8}"
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-dev \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/elc \
|
||||
--package=el-elc \
|
||||
--version="${VERSION}" \
|
||||
--source=dist/platform/elc
|
||||
|
||||
@@ -198,7 +223,15 @@ jobs:
|
||||
--repository=foundation-dev \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.c \
|
||||
--package=el-elb \
|
||||
--version="${VERSION}" \
|
||||
--source=dist/bin/elb
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-dev \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el-runtime-c \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.c
|
||||
|
||||
@@ -206,9 +239,17 @@ jobs:
|
||||
--repository=foundation-dev \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.h \
|
||||
--package=el-runtime-h \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.h
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-dev \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el-runtime-js \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.js
|
||||
|
||||
echo "Published El SDK version=${VERSION} to foundation-dev"
|
||||
rm -f /tmp/gcp-key.json
|
||||
|
||||
@@ -34,35 +34,25 @@ jobs:
|
||||
apt-get update -qq
|
||||
apt-get install -y gcc libcurl4-openssl-dev
|
||||
|
||||
# Gen2: compile the bootstrap C source into a working elc binary
|
||||
- name: Build elc from bootstrap (gen2)
|
||||
# Seed: use the committed linux-amd64 binary as the bootstrap
|
||||
- name: Bootstrap from committed linux binary (seed)
|
||||
run: |
|
||||
# -Wl,--allow-multiple-definition: elc-bootstrap.c and el_runtime.c both define
|
||||
# is_digit/is_whitespace; bootstrap predates the text-processing primitives commit
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-bootstrap.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-Wl,--allow-multiple-definition \
|
||||
-o dist/elc-gen2
|
||||
chmod +x dist/elc-gen2
|
||||
echo "gen2 elc built"
|
||||
dist/elc-gen2 --version || true
|
||||
chmod +x dist/platform/elc-linux-amd64
|
||||
echo "seed elc (committed linux-amd64 binary)"
|
||||
dist/platform/elc-linux-amd64 --version || true
|
||||
|
||||
# Gen3: use gen2 to compile the El compiler from its own El source (self-host)
|
||||
- name: Self-host compile El compiler with gen2 (gen3)
|
||||
# Gen2: use seed to self-host compile the El compiler
|
||||
- name: Self-host compile El compiler (gen2)
|
||||
run: |
|
||||
mkdir -p dist/platform
|
||||
dist/elc-gen2 el-compiler/src/compiler.el > dist/elc-gen3.c
|
||||
dist/platform/elc-linux-amd64 elc-cli.el > dist/elc-gen2.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-gen3.c \
|
||||
dist/elc-gen2.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/platform/elc
|
||||
chmod +x dist/platform/elc
|
||||
echo "gen3 (self-hosted) elc built"
|
||||
echo "gen2 (self-hosted) elc built"
|
||||
dist/platform/elc --version || true
|
||||
|
||||
- name: Run tests - text
|
||||
@@ -97,7 +87,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_core.el > /tmp/el_native_core.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_core.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_core
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_core
|
||||
/tmp/el_native_core
|
||||
|
||||
- name: Run tests - native (text)
|
||||
@@ -107,7 +97,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_text.el > /tmp/el_native_text.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_text.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_text
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_text
|
||||
/tmp/el_native_text
|
||||
|
||||
- name: Run tests - native (string)
|
||||
@@ -117,7 +107,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_string.el > /tmp/el_native_string.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_string.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_string
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_string
|
||||
/tmp/el_native_string
|
||||
|
||||
- name: Run tests - native (math)
|
||||
@@ -127,7 +117,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_math.el > /tmp/el_native_math.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_math.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_math
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_math
|
||||
/tmp/el_native_math
|
||||
|
||||
- name: Run tests - native (state)
|
||||
@@ -137,7 +127,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_state.el > /tmp/el_native_state.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_state.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_state
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_state
|
||||
/tmp/el_native_state
|
||||
|
||||
- name: Run tests - native (time)
|
||||
@@ -147,7 +137,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_time.el > /tmp/el_native_time.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_time.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_time
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_time
|
||||
/tmp/el_native_time
|
||||
|
||||
- name: Run tests - native (json)
|
||||
@@ -157,7 +147,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_json.el > /tmp/el_native_json.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_json.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_json
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_json
|
||||
/tmp/el_native_json
|
||||
|
||||
- name: Run tests - native (env)
|
||||
@@ -167,7 +157,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_env.el > /tmp/el_native_env.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_env.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_env
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_env
|
||||
/tmp/el_native_env
|
||||
|
||||
- name: Run tests - native (fs)
|
||||
@@ -177,9 +167,45 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_fs.el > /tmp/el_native_fs.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_fs.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_fs
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_fs
|
||||
/tmp/el_native_fs
|
||||
|
||||
# Build elb (needed for epm and el-install builds below)
|
||||
- name: Build elb
|
||||
run: |
|
||||
mkdir -p dist/bin
|
||||
dist/platform/elc elb.el > dist/elb.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elb.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/bin/elb
|
||||
chmod +x dist/bin/elb
|
||||
echo "elb built"
|
||||
|
||||
# Build epm binary using elb (epm lives at repo root, not inside lang/)
|
||||
- name: Build epm
|
||||
run: |
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd ../epm && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/epm
|
||||
echo "epm built"
|
||||
|
||||
# Build el-install binary using elb
|
||||
- name: Build el-install
|
||||
run: |
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd tools/install && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/el-install
|
||||
echo "el-install built"
|
||||
|
||||
# Publish only after merge (push event), not on PR validation runs
|
||||
- name: Publish El SDK to Artifact Registry (stage)
|
||||
if: github.event_name == 'push'
|
||||
@@ -187,20 +213,19 @@ jobs:
|
||||
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 install -y -qq apt-transport-https ca-certificates curl
|
||||
echo "deb [trusted=yes] 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
|
||||
|
||||
VERSION="${GITEA_SHA:0:8}"
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-stage \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/elc \
|
||||
--package=el-elc \
|
||||
--version="${VERSION}" \
|
||||
--source=dist/platform/elc
|
||||
|
||||
@@ -208,7 +233,7 @@ jobs:
|
||||
--repository=foundation-stage \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.c \
|
||||
--package=el-runtime-c \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.c
|
||||
|
||||
@@ -216,7 +241,7 @@ jobs:
|
||||
--repository=foundation-stage \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.h \
|
||||
--package=el-runtime-h \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.h
|
||||
|
||||
|
||||
@@ -34,35 +34,26 @@ jobs:
|
||||
apt-get update -qq
|
||||
apt-get install -y gcc libcurl4-openssl-dev
|
||||
|
||||
# Gen2: compile the bootstrap C source into a working elc binary
|
||||
- name: Build elc from bootstrap (gen2)
|
||||
# Seed: use the committed linux-amd64 binary as the bootstrap
|
||||
- name: Bootstrap from committed linux binary (seed)
|
||||
run: |
|
||||
# -Wl,--allow-multiple-definition: elc-bootstrap.c and el_runtime.c both define
|
||||
# is_digit/is_whitespace; bootstrap predates the text-processing primitives commit
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-bootstrap.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-Wl,--allow-multiple-definition \
|
||||
-o dist/elc-gen2
|
||||
chmod +x dist/elc-gen2
|
||||
echo "gen2 elc built"
|
||||
dist/elc-gen2 --version || true
|
||||
chmod +x dist/platform/elc-linux-amd64
|
||||
echo "seed elc (committed linux-amd64 binary)"
|
||||
dist/platform/elc-linux-amd64 --version || true
|
||||
|
||||
# Gen3: use gen2 to compile the El compiler from its own El source (self-host)
|
||||
- name: Self-host compile El compiler with gen2 (gen3)
|
||||
# Gen2: use seed to self-host compile the El compiler
|
||||
- name: Self-host compile El compiler (gen2)
|
||||
run: |
|
||||
mkdir -p dist/platform
|
||||
dist/elc-gen2 el-compiler/src/compiler.el > dist/elc-gen3.c
|
||||
dist/platform/elc-linux-amd64 elc-cli.el > dist/elc-gen2.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/elc-gen3.c \
|
||||
dist/elc-gen2.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/platform/elc
|
||||
chmod +x dist/platform/elc
|
||||
echo "gen3 (self-hosted) elc built"
|
||||
echo "gen2 (self-hosted) elc built"
|
||||
dist/platform/elc --version || true
|
||||
|
||||
# Build elb binary
|
||||
@@ -74,34 +65,30 @@ jobs:
|
||||
-I el-compiler/runtime \
|
||||
dist/elb.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-lcurl -lssl -lcrypto -lpthread -lm \
|
||||
-o dist/bin/elb
|
||||
chmod +x dist/bin/elb
|
||||
echo "elb built"
|
||||
|
||||
# Build epm binary (epm lives at repo root, not inside lang/)
|
||||
# Build epm binary using elb (epm lives at repo root, not inside lang/)
|
||||
- name: Build epm
|
||||
run: |
|
||||
dist/platform/elc ../epm/src/epm.el > dist/epm.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/epm.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-o dist/bin/epm
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd ../epm && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/epm
|
||||
echo "epm built"
|
||||
|
||||
# Build el-install binary
|
||||
# Build el-install binary using elb
|
||||
- name: Build el-install
|
||||
run: |
|
||||
dist/platform/elc tools/install/el-install.el > dist/el-install.c
|
||||
gcc -O2 \
|
||||
-I el-compiler/runtime \
|
||||
dist/el-install.c \
|
||||
el-compiler/runtime/el_runtime.c \
|
||||
-lcurl -lpthread -lm \
|
||||
-o dist/bin/el-install
|
||||
ABS_ELB="$(pwd)/dist/bin/elb"
|
||||
ABS_ELC="$(pwd)/dist/platform/elc"
|
||||
ABS_RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
ABS_OUT="$(pwd)/dist/bin"
|
||||
(cd tools/install && "$ABS_ELB" --clean --elc="$ABS_ELC" --runtime="$ABS_RUNTIME" --out="$ABS_OUT")
|
||||
chmod +x dist/bin/el-install
|
||||
echo "el-install built"
|
||||
|
||||
@@ -137,7 +124,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_core.el > /tmp/el_native_core.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_core.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_core
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_core
|
||||
/tmp/el_native_core
|
||||
|
||||
- name: Run tests - native (text)
|
||||
@@ -147,7 +134,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_text.el > /tmp/el_native_text.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_text.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_text
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_text
|
||||
/tmp/el_native_text
|
||||
|
||||
- name: Run tests - native (string)
|
||||
@@ -157,7 +144,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_string.el > /tmp/el_native_string.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_string.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_string
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_string
|
||||
/tmp/el_native_string
|
||||
|
||||
- name: Run tests - native (math)
|
||||
@@ -167,7 +154,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_math.el > /tmp/el_native_math.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_math.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_math
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_math
|
||||
/tmp/el_native_math
|
||||
|
||||
- name: Run tests - native (state)
|
||||
@@ -177,7 +164,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_state.el > /tmp/el_native_state.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_state.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_state
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_state
|
||||
/tmp/el_native_state
|
||||
|
||||
- name: Run tests - native (time)
|
||||
@@ -187,7 +174,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_time.el > /tmp/el_native_time.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_time.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_time
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_time
|
||||
/tmp/el_native_time
|
||||
|
||||
- name: Run tests - native (json)
|
||||
@@ -197,7 +184,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_json.el > /tmp/el_native_json.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_json.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_json
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_json
|
||||
/tmp/el_native_json
|
||||
|
||||
- name: Run tests - native (env)
|
||||
@@ -207,7 +194,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_env.el > /tmp/el_native_env.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_env.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_env
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_env
|
||||
/tmp/el_native_env
|
||||
|
||||
- name: Run tests - native (fs)
|
||||
@@ -217,7 +204,7 @@ jobs:
|
||||
RUNTIME="$(pwd)/el-compiler/runtime"
|
||||
"$ELC" --test tests/native/test_fs.el > /tmp/el_native_fs.c
|
||||
gcc -O2 -I "$RUNTIME" /tmp/el_native_fs.c "$RUNTIME/el_runtime.c" \
|
||||
-lcurl -lpthread -lm -o /tmp/el_native_fs
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/el_native_fs
|
||||
/tmp/el_native_fs
|
||||
|
||||
# Bundle the SDK tarball - runs from the repo root to reference lang/ paths correctly
|
||||
@@ -241,7 +228,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||
GITEA_API: https://git.neuralplatform.ai/api/v1
|
||||
REPO: neuron-technologies/el
|
||||
run: |
|
||||
@@ -302,20 +289,19 @@ jobs:
|
||||
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 install -y -qq apt-transport-https ca-certificates curl
|
||||
echo "deb [trusted=yes] 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
|
||||
|
||||
VERSION="${GITEA_SHA:0:8}"
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-prod \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/elc \
|
||||
--package=el-elc \
|
||||
--version="${VERSION}" \
|
||||
--source=dist/platform/elc
|
||||
|
||||
@@ -323,7 +309,15 @@ jobs:
|
||||
--repository=foundation-prod \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.c \
|
||||
--package=el-elb \
|
||||
--version="${VERSION}" \
|
||||
--source=dist/bin/elb
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-prod \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el-runtime-c \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.c
|
||||
|
||||
@@ -331,20 +325,75 @@ jobs:
|
||||
--repository=foundation-prod \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el/el_runtime.h \
|
||||
--package=el-runtime-h \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.h
|
||||
|
||||
gcloud artifacts generic upload \
|
||||
--repository=foundation-prod \
|
||||
--location=us-central1 \
|
||||
--project=neuron-785695 \
|
||||
--package=el-runtime-js \
|
||||
--version="${VERSION}" \
|
||||
--source=el-compiler/runtime/el_runtime.js
|
||||
|
||||
echo "Published El SDK version=${VERSION} to foundation-prod"
|
||||
# Keep key alive for the ci-base rebuild step below
|
||||
# (deleted in that step after docker push)
|
||||
|
||||
- name: Rebuild ci-base with fresh El SDK
|
||||
# Patches ci-base:latest in-place: pulls the existing image (which has all
|
||||
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
|
||||
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CI_BASE="us-central1-docker.pkg.dev/neuron-785695/neuron-ci/ci-base"
|
||||
SHA="${GITHUB_SHA:0:8}"
|
||||
|
||||
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
|
||||
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
# Pull existing ci-base (system deps stay cached in the base layer)
|
||||
docker pull "${CI_BASE}:latest"
|
||||
|
||||
# Inline Dockerfile — only replaces the El SDK layer
|
||||
cat > /tmp/Dockerfile.ci-base-patch << 'EOF'
|
||||
ARG BASE
|
||||
FROM ${BASE}
|
||||
COPY dist/platform/elc /opt/el/dist/platform/elc
|
||||
COPY dist/bin/elb /opt/el/dist/bin/elb
|
||||
COPY el-compiler/runtime/el_runtime.c /opt/el/el-compiler/runtime/el_runtime.c
|
||||
COPY el-compiler/runtime/el_runtime.h /opt/el/el-compiler/runtime/el_runtime.h
|
||||
COPY el-compiler/runtime/el_runtime.js /opt/el/el-compiler/runtime/el_runtime.js
|
||||
RUN chmod +x /opt/el/dist/platform/elc /opt/el/dist/bin/elb
|
||||
EOF
|
||||
|
||||
docker build \
|
||||
--build-arg BASE="${CI_BASE}:latest" \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-f /tmp/Dockerfile.ci-base-patch \
|
||||
-t "${CI_BASE}:latest" \
|
||||
-t "${CI_BASE}:${SHA}" \
|
||||
.
|
||||
|
||||
docker push "${CI_BASE}:latest"
|
||||
docker push "${CI_BASE}:${SHA}"
|
||||
|
||||
echo "ci-base rebuilt: ${CI_BASE}:latest (${SHA})"
|
||||
rm -f /tmp/gcp-key.json
|
||||
|
||||
- name: Dispatch el-sdk-updated to downstream repos
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||
GITEA_API: https://git.neuralplatform.ai/api/v1
|
||||
run: |
|
||||
for repo in neuron-technologies/forge; do
|
||||
for repo in neuron-technologies/forge neuron-technologies/neuron-web; do
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -177,15 +177,17 @@ static el_val_t el_wrap_str(char* s) {
|
||||
|
||||
/* ── I/O ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
void println(el_val_t s) {
|
||||
el_val_t println(el_val_t s) {
|
||||
const char* str = EL_CSTR(s);
|
||||
if (str) puts(str);
|
||||
else puts("");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void print(el_val_t s) {
|
||||
el_val_t print(el_val_t s) {
|
||||
const char* str = EL_CSTR(s);
|
||||
if (str) fputs(str, stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t readline(void) {
|
||||
@@ -292,6 +294,10 @@ el_val_t str_to_int(el_val_t sv) {
|
||||
return (el_val_t)atoll(s);
|
||||
}
|
||||
|
||||
/* native_str_to_int — El compiler-generated alias for str_to_int.
|
||||
* Converts a string el_val_t to its integer representation. */
|
||||
el_val_t native_str_to_int(el_val_t sv) { return str_to_int(sv); }
|
||||
|
||||
el_val_t str_slice(el_val_t sv, el_val_t start, el_val_t end) {
|
||||
const char* s = EL_CSTR(sv);
|
||||
if (!s) return el_wrap_str(el_strdup(""));
|
||||
@@ -907,6 +913,33 @@ el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_ma
|
||||
return r;
|
||||
}
|
||||
|
||||
/* http_post_json_with_headers — POST with Content-Type: application/json plus
|
||||
* any additional headers supplied as an El map. Combines http_post_json and
|
||||
* http_post_with_headers: the Content-Type header is always prepended so
|
||||
* callers do not have to include it in their map. */
|
||||
el_val_t http_post_json_with_headers(el_val_t url, el_val_t headers_map, el_val_t json_body) {
|
||||
struct curl_slist* h = NULL;
|
||||
h = curl_slist_append(h, "Content-Type: application/json");
|
||||
/* Append caller-supplied headers from the map */
|
||||
ElMap* m = as_map(headers_map);
|
||||
if (m) {
|
||||
for (int64_t i = 0; i < m->count; i++) {
|
||||
const char* k = EL_CSTR(m->keys[i]);
|
||||
const char* v = EL_CSTR(m->values[i]);
|
||||
if (!k || !v) continue;
|
||||
size_t n = strlen(k) + strlen(v) + 4;
|
||||
char* line = malloc(n);
|
||||
if (!line) continue;
|
||||
snprintf(line, n, "%s: %s", k, v);
|
||||
h = curl_slist_append(h, line);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(json_body), h);
|
||||
curl_slist_free_all(h);
|
||||
return r;
|
||||
}
|
||||
|
||||
el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header) {
|
||||
struct curl_slist* h = NULL;
|
||||
h = curl_slist_append(h, "Content-Type: application/x-www-form-urlencoded");
|
||||
@@ -1059,7 +1092,7 @@ void el_runtime_register_handler(const char* name, http_handler_fn fn) {
|
||||
pthread_mutex_unlock(&_http_handler_mu);
|
||||
}
|
||||
|
||||
void http_set_handler(el_val_t name) {
|
||||
el_val_t http_set_handler(el_val_t name) {
|
||||
const char* n = EL_CSTR(name);
|
||||
pthread_mutex_lock(&_http_handler_mu);
|
||||
free(_http_active_handler);
|
||||
@@ -1083,6 +1116,7 @@ void http_set_handler(el_val_t name) {
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&_http_handler_mu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static http_handler_fn http_lookup_active(void) {
|
||||
@@ -1540,18 +1574,18 @@ static void* http_worker(void* arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void http_serve(el_val_t port, el_val_t handler) {
|
||||
el_val_t http_serve(el_val_t port, el_val_t handler) {
|
||||
/* If `handler` looks like a string name, register it as the active handler. */
|
||||
const char* hname = EL_CSTR(handler);
|
||||
if (hname && looks_like_string(handler)) {
|
||||
http_set_handler(handler);
|
||||
}
|
||||
int p = (int)port;
|
||||
if (p <= 0 || p > 65535) { fprintf(stderr, "http_serve: invalid port %d\n", p); return; }
|
||||
if (p <= 0 || p > 65535) { fprintf(stderr, "http_serve: invalid port %d\n", p); return 0; }
|
||||
/* Dual-stack: AF_INET6 with IPV6_V6ONLY=0 accepts both IPv4 and IPv6.
|
||||
* This makes `localhost` work in browsers that resolve it to ::1 first. */
|
||||
int sock = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
if (sock < 0) { perror("socket"); return; }
|
||||
if (sock < 0) { perror("socket"); return 0; }
|
||||
int yes = 1; int no = 0;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
|
||||
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
|
||||
@@ -1561,9 +1595,9 @@ void http_serve(el_val_t port, el_val_t handler) {
|
||||
addr.sin6_addr = in6addr_any;
|
||||
addr.sin6_port = htons((uint16_t)p);
|
||||
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
|
||||
perror("bind"); close(sock); return;
|
||||
perror("bind"); close(sock); return 0;
|
||||
}
|
||||
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
|
||||
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return 0; }
|
||||
fprintf(stderr, "[http] listening on [::]:%d (dual-stack)\n", p);
|
||||
while (1) {
|
||||
struct sockaddr_in6 cli;
|
||||
@@ -1594,6 +1628,7 @@ void http_serve(el_val_t port, el_val_t handler) {
|
||||
pthread_detach(tid);
|
||||
}
|
||||
close(sock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── HTTP server v2 — request headers + structured response ──────────────── */
|
||||
@@ -1645,7 +1680,7 @@ void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn) {
|
||||
pthread_mutex_unlock(&_http_handler_mu);
|
||||
}
|
||||
|
||||
void http_set_handler_v2(el_val_t name) {
|
||||
el_val_t http_set_handler_v2(el_val_t name) {
|
||||
const char* n = EL_CSTR(name);
|
||||
pthread_mutex_lock(&_http_handler_mu);
|
||||
free(_http_active_handler4);
|
||||
@@ -1667,6 +1702,7 @@ void http_set_handler_v2(el_val_t name) {
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&_http_handler_mu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static http_handler4_fn http_lookup_active_v2(void) {
|
||||
@@ -1787,7 +1823,7 @@ static void* http_worker_v2(void* arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
el_val_t http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
const char* hname = EL_CSTR(handler);
|
||||
if (hname && looks_like_string(handler)) {
|
||||
http_set_handler_v2(handler);
|
||||
@@ -1795,11 +1831,11 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
int p = (int)port;
|
||||
if (p <= 0 || p > 65535) {
|
||||
fprintf(stderr, "http_serve_v2: invalid port %d\n", p);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
/* Dual-stack: same as http_serve - AF_INET6 + IPV6_V6ONLY=0. */
|
||||
int sock = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
if (sock < 0) { perror("socket"); return; }
|
||||
if (sock < 0) { perror("socket"); return 0; }
|
||||
int yes = 1; int no = 0;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
|
||||
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
|
||||
@@ -1809,9 +1845,9 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
addr.sin6_addr = in6addr_any;
|
||||
addr.sin6_port = htons((uint16_t)p);
|
||||
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
|
||||
perror("bind"); close(sock); return;
|
||||
perror("bind"); close(sock); return 0;
|
||||
}
|
||||
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
|
||||
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return 0; }
|
||||
fprintf(stderr, "[http v2] listening on [::]:%d (dual-stack)\n", p);
|
||||
while (1) {
|
||||
struct sockaddr_in6 cli;
|
||||
@@ -1842,6 +1878,7 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
pthread_detach(tid);
|
||||
}
|
||||
close(sock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build the response envelope a 4-arg handler can return. We hand-write
|
||||
@@ -3098,23 +3135,49 @@ static void jb_puts(JsonBuf* b, const char* s) {
|
||||
|
||||
static void jb_emit_escaped(JsonBuf* b, const char* s) {
|
||||
jb_putc(b, '"');
|
||||
for (; *s; s++) {
|
||||
unsigned char c = (unsigned char)*s;
|
||||
const unsigned char* p = (const unsigned char*)s;
|
||||
while (*p) {
|
||||
unsigned char c = *p;
|
||||
switch (c) {
|
||||
case '"': jb_puts(b, "\\\""); break;
|
||||
case '\\': jb_puts(b, "\\\\"); break;
|
||||
case '\b': jb_puts(b, "\\b"); break;
|
||||
case '\f': jb_puts(b, "\\f"); break;
|
||||
case '\n': jb_puts(b, "\\n"); break;
|
||||
case '\r': jb_puts(b, "\\r"); break;
|
||||
case '\t': jb_puts(b, "\\t"); break;
|
||||
case '"': jb_puts(b, "\\\""); p++; break;
|
||||
case '\\': jb_puts(b, "\\\\"); p++; break;
|
||||
case '\b': jb_puts(b, "\\b"); p++; break;
|
||||
case '\f': jb_puts(b, "\\f"); p++; break;
|
||||
case '\n': jb_puts(b, "\\n"); p++; break;
|
||||
case '\r': jb_puts(b, "\\r"); p++; break;
|
||||
case '\t': jb_puts(b, "\\t"); p++; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char tmp[8];
|
||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||
jb_puts(b, tmp);
|
||||
} else {
|
||||
p++;
|
||||
} else if (c < 0x80) {
|
||||
jb_putc(b, (char)c);
|
||||
p++;
|
||||
} else {
|
||||
/* Multi-byte UTF-8: validate sequence, pass through if valid,
|
||||
* escape as \u00xx if the start byte is invalid/orphaned. */
|
||||
int seq_len = 0;
|
||||
if ((c & 0xE0) == 0xC0) seq_len = 2;
|
||||
else if ((c & 0xF0) == 0xE0) seq_len = 3;
|
||||
else if ((c & 0xF8) == 0xF0) seq_len = 4;
|
||||
if (seq_len >= 2) {
|
||||
int valid = 1;
|
||||
for (int i = 1; i < seq_len; i++) {
|
||||
if ((p[i] & 0xC0) != 0x80) { valid = 0; break; }
|
||||
}
|
||||
if (valid) {
|
||||
for (int i = 0; i < seq_len; i++) jb_putc(b, (char)p[i]);
|
||||
p += seq_len;
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* Invalid start byte or truncated sequence — escape it */
|
||||
char tmp[8];
|
||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||
jb_puts(b, tmp);
|
||||
p++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -4999,7 +5062,13 @@ el_val_t state_get_or(el_val_t key, el_val_t default_val) {
|
||||
|
||||
el_val_t float_to_str(el_val_t f) {
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof(buf), "%g", el_to_float(f));
|
||||
double v = el_to_float(f);
|
||||
/* Normalize NaN to "nan" regardless of sign — platform-independent. */
|
||||
if (isnan(v)) {
|
||||
snprintf(buf, sizeof(buf), "nan");
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%g", v);
|
||||
}
|
||||
return el_wrap_str(el_strdup(buf));
|
||||
}
|
||||
|
||||
@@ -5619,8 +5688,9 @@ el_val_t parse_int(el_val_t sv, el_val_t default_val) {
|
||||
|
||||
/* ── Process ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
void exit_program(el_val_t code) {
|
||||
el_val_t exit_program(el_val_t code) {
|
||||
exit((int)code);
|
||||
return 0; /* unreachable */
|
||||
}
|
||||
|
||||
/* getpid_now — current process id. Named with the _now suffix to avoid
|
||||
@@ -8403,7 +8473,7 @@ static el_val_t llm_provider_request(const char* url, const char* key,
|
||||
}
|
||||
}
|
||||
|
||||
static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
|
||||
static el_val_t llm_chain_call(const char* model_pref, const char* system_str, const char* user_str) {
|
||||
char url_key[64], key_key[64], fmt_key[64], model_key[64];
|
||||
for (int i = 0; i < LLM_MAX_PROVIDERS; i++) {
|
||||
snprintf(url_key, sizeof(url_key), "NEURON_LLM_%d_URL", i);
|
||||
@@ -8416,6 +8486,7 @@ static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
|
||||
const char* fmt_s = getenv(fmt_key);
|
||||
int fmt = (fmt_s && strcmp(fmt_s, "anthropic") == 0) ? 1 : 0;
|
||||
const char* model = getenv(model_key);
|
||||
if (!model || !*model) model = model_pref; /* fall back to the caller-requested model */
|
||||
fprintf(stderr, "[llm] trying provider %d (%s)\n", i, url);
|
||||
el_val_t result = llm_provider_request(url, key, fmt, model, system_str, user_str);
|
||||
const char* t = EL_CSTR(result);
|
||||
@@ -8426,7 +8497,7 @@ static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
|
||||
const char* api_key = getenv("ANTHROPIC_API_KEY");
|
||||
if (!api_key || !*api_key) return http_error_json("no LLM providers configured");
|
||||
fprintf(stderr, "[llm] using legacy ANTHROPIC_API_KEY fallback\n");
|
||||
return llm_provider_request(LLM_API_URL, api_key, 1, NULL, system_str, user_str);
|
||||
return llm_provider_request(LLM_API_URL, api_key, 1, model_pref, system_str, user_str);
|
||||
}
|
||||
|
||||
/* Legacy llm_request — kept for backward compat with agentic loop internals */
|
||||
@@ -8490,14 +8561,16 @@ static el_val_t llm_extract_text(el_val_t resp_val) {
|
||||
}
|
||||
|
||||
el_val_t llm_call(el_val_t model, el_val_t prompt) {
|
||||
const char* m = EL_CSTR(model);
|
||||
const char* u = EL_CSTR(prompt); if (!u) u = "";
|
||||
return llm_chain_call(NULL, u);
|
||||
return llm_chain_call(m, NULL, u);
|
||||
}
|
||||
|
||||
el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) {
|
||||
const char* m = EL_CSTR(model);
|
||||
const char* s = EL_CSTR(system_prompt); if (!s) s = "";
|
||||
const char* u = EL_CSTR(user_prompt); if (!u) u = "";
|
||||
return llm_chain_call(s, u);
|
||||
return llm_chain_call(m, s, u);
|
||||
}
|
||||
|
||||
/* ── Tool registry for llm_call_agentic ─────────────────────────────────── */
|
||||
@@ -11177,6 +11250,175 @@ el_val_t hash_sha256(el_val_t sv) {
|
||||
return el_hex_encode(digest, 32);
|
||||
}
|
||||
|
||||
/* ── __ prefixed aliases — public boundary for compiled El programs ──────────
|
||||
*
|
||||
* The El compiler's self-hosting back-end emits calls to __-prefixed function
|
||||
* names (e.g. __println, __str_len). These wrappers forward to the existing
|
||||
* el_runtime implementations so both naming conventions resolve at link time.
|
||||
*
|
||||
* Note: __thread_create and __thread_join are already defined above in the
|
||||
* threading section; they are not repeated here.
|
||||
* ──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* I/O */
|
||||
el_val_t __println(el_val_t s) { return println(s); }
|
||||
el_val_t __print(el_val_t s) { return print(s); }
|
||||
el_val_t __readline(void) { return readline(); }
|
||||
|
||||
/* String */
|
||||
el_val_t __int_to_str(el_val_t n) { return int_to_str(n); }
|
||||
el_val_t __str_to_int(el_val_t s) { return str_to_int(s); }
|
||||
el_val_t __float_to_str(el_val_t f) { return float_to_str(f); }
|
||||
el_val_t __str_to_float(el_val_t s) { return str_to_float(s); }
|
||||
el_val_t __str_len(el_val_t s) { return str_len(s); }
|
||||
el_val_t __str_char_at(el_val_t s, el_val_t i) { return str_char_at(s, i); }
|
||||
|
||||
el_val_t __str_cmp(el_val_t a, el_val_t b) {
|
||||
const char* ca = EL_CSTR(a);
|
||||
const char* cb = EL_CSTR(b);
|
||||
if (!ca) ca = "";
|
||||
if (!cb) cb = "";
|
||||
return (el_val_t)strcmp(ca, cb);
|
||||
}
|
||||
|
||||
el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n) {
|
||||
const char* ca = EL_CSTR(a);
|
||||
const char* cb = EL_CSTR(b);
|
||||
if (!ca) ca = "";
|
||||
if (!cb) cb = "";
|
||||
return (el_val_t)strncmp(ca, cb, (size_t)n);
|
||||
}
|
||||
|
||||
el_val_t __str_concat_raw(el_val_t a, el_val_t b) { return str_concat(a, b); }
|
||||
el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end) { return str_slice(s, start, end); }
|
||||
|
||||
el_val_t __str_alloc(el_val_t n) {
|
||||
if (n <= 0) n = 0;
|
||||
char* buf = el_strbuf((size_t)n + 1);
|
||||
memset(buf, 0, (size_t)n + 1);
|
||||
return el_wrap_str(buf);
|
||||
}
|
||||
|
||||
el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
|
||||
char* buf = (char*)(uintptr_t)s;
|
||||
if (buf) buf[(size_t)i] = (char)c;
|
||||
return s;
|
||||
}
|
||||
|
||||
/* URL encoding */
|
||||
el_val_t __url_encode(el_val_t s) { return url_encode(s); }
|
||||
el_val_t __url_decode(el_val_t s) { return url_decode(s); }
|
||||
|
||||
/* Environment */
|
||||
el_val_t __env_get(el_val_t key) { return env(key); }
|
||||
|
||||
/* Subprocess */
|
||||
el_val_t __exec(el_val_t cmd) { return exec(cmd); }
|
||||
el_val_t __exec_bg(el_val_t cmd) { return exec_bg(cmd); }
|
||||
|
||||
/* Process */
|
||||
el_val_t __exit_program(el_val_t code) { return exit_program(code); }
|
||||
|
||||
/* Filesystem */
|
||||
el_val_t __fs_exists(el_val_t path) { return fs_exists(path); }
|
||||
el_val_t __fs_mkdir(el_val_t path) { return fs_mkdir(path); }
|
||||
el_val_t __fs_read(el_val_t path) { return fs_read(path); }
|
||||
el_val_t __fs_write(el_val_t path, el_val_t content) { return fs_write(path, content); }
|
||||
el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n) { return fs_write_bytes(path, bytes, n); }
|
||||
el_val_t __fs_list_raw(el_val_t path) { return fs_list_json(path); }
|
||||
|
||||
/* HTTP server (no curl dependency) */
|
||||
el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body) { return http_response(status, headers_json, body); }
|
||||
el_val_t __http_serve(el_val_t port, el_val_t handler) { return http_serve(port, handler); }
|
||||
el_val_t __http_serve_v2(el_val_t port, el_val_t handler) { return http_serve_v2(port, handler); }
|
||||
|
||||
/* HTTP conn fd / SSE — __http_conn_fd lives in el_seed.c; stubs provided here
|
||||
* so el_runtime.c compiles standalone. When both translation units are linked
|
||||
* the el_seed.c definitions win via their non-static linkage (strong symbols).
|
||||
* These stubs are marked weak so they are silently overridden. */
|
||||
__attribute__((weak)) el_val_t __http_conn_fd(void) { return (el_val_t)(-1); }
|
||||
__attribute__((weak)) el_val_t __http_sse_open(el_val_t conn_id) { (void)conn_id; return 0; }
|
||||
__attribute__((weak)) el_val_t __http_sse_send(el_val_t conn_id, el_val_t data) { (void)conn_id; (void)data; return 0; }
|
||||
__attribute__((weak)) el_val_t __http_sse_close(el_val_t conn_id) { (void)conn_id; return 0; }
|
||||
|
||||
/* JSON */
|
||||
el_val_t __json_array_get(el_val_t json, el_val_t index) { return json_array_get(json, index); }
|
||||
el_val_t __json_array_get_string(el_val_t json, el_val_t index) { return json_array_get_string(json, index); }
|
||||
el_val_t __json_array_len(el_val_t json) { return json_array_len(json); }
|
||||
el_val_t __json_get(el_val_t json, el_val_t key) { return json_get(json, key); }
|
||||
el_val_t __json_get_raw(el_val_t json, el_val_t key) { return json_get_raw(json, key); }
|
||||
el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value){ return json_set(json, key, value); }
|
||||
el_val_t __json_parse_map(el_val_t json_str) { return json_parse(json_str); }
|
||||
el_val_t __json_stringify_val(el_val_t val) { return json_stringify(val); }
|
||||
|
||||
/* Hashing */
|
||||
el_val_t __sha256_hex(el_val_t s) { return hash_sha256(s); }
|
||||
|
||||
/* State K/V */
|
||||
el_val_t __state_del(el_val_t key) { return state_del(key); }
|
||||
el_val_t __state_get(el_val_t key) { return state_get(key); }
|
||||
el_val_t __state_keys(void) { return state_keys(); }
|
||||
el_val_t __state_set(el_val_t key, el_val_t val) { return state_set(key, val); }
|
||||
|
||||
/* UUID */
|
||||
el_val_t __uuid_v4(void) { return uuid_v4(); }
|
||||
|
||||
/* Args */
|
||||
el_val_t __args_json(void) { return args(); }
|
||||
|
||||
/* HTTP client aliases — require curl; defined inside #ifdef HAVE_CURL below
|
||||
* with a matching stub in the #ifndef HAVE_CURL block. */
|
||||
#ifdef HAVE_CURL
|
||||
el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_map, el_val_t timeout_ms) {
|
||||
/* timeout_ms is accepted for API compatibility but ignored here;
|
||||
* el_runtime's http_do uses the EL_HTTP_TIMEOUT_MS env var instead. */
|
||||
(void)timeout_ms;
|
||||
struct curl_slist* h = headers_from_map(headers_map);
|
||||
el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
|
||||
if (h) curl_slist_free_all(h);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* __http_do_map — same as __http_do but headers_map arg is a JSON-string
|
||||
* rather than an ElMap. Parse it first, then delegate. */
|
||||
el_val_t __http_do_map(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t timeout_ms) {
|
||||
(void)timeout_ms;
|
||||
/* Build a curl_slist from a JSON object {"Header":"value",...}. */
|
||||
const char* hj = EL_CSTR(headers_json);
|
||||
struct curl_slist* h = NULL;
|
||||
if (hj && *hj && *hj == '{') {
|
||||
/* Walk the JSON pairs with a simple parser reusing json_get_string logic. */
|
||||
/* For correctness we just call the existing json_get iteration path.
|
||||
* We duplicate the key-extraction loop from headers_from_map but driven
|
||||
* by JSON rather than ElMap. Use json_get_raw to iterate is not easy
|
||||
* without knowing keys, so accept the JSON string and build a tmp map. */
|
||||
el_val_t map = json_parse(EL_STR(hj));
|
||||
h = headers_from_map(map);
|
||||
}
|
||||
el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
|
||||
if (h) curl_slist_free_all(h);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* __http_do_map_to_file — same as __http_do_map but streams response body
|
||||
* to a local file path rather than returning it as a string. */
|
||||
el_val_t __http_do_map_to_file(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t output_path) {
|
||||
const char* hj = EL_CSTR(headers_json);
|
||||
struct curl_slist* h = NULL;
|
||||
if (hj && *hj && *hj == '{') {
|
||||
el_val_t map = json_parse(EL_STR(hj));
|
||||
h = headers_from_map(map);
|
||||
}
|
||||
el_val_t r = http_do_to_file(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body),
|
||||
h, EL_CSTR(output_path));
|
||||
if (h) curl_slist_free_all(h);
|
||||
return r;
|
||||
}
|
||||
#endif /* HAVE_CURL */
|
||||
|
||||
#ifndef HAVE_CURL
|
||||
/* ── HAVE_CURL=0 stubs — compile without -lcurl for the elc CLI binary. ───── *
|
||||
* These return a JSON error string so El programs get a clear message if they
|
||||
@@ -11189,6 +11431,7 @@ el_val_t http_post(el_val_t url, el_val_t body) { (void)url; (void)body
|
||||
el_val_t http_post_json(el_val_t url, el_val_t body) { (void)url; (void)body; return _no_curl_err(); }
|
||||
el_val_t http_get_with_headers(el_val_t url, el_val_t h) { (void)url; (void)h; return _no_curl_err(); }
|
||||
el_val_t http_post_with_headers(el_val_t url, el_val_t b, el_val_t h) { (void)url; (void)b; (void)h; return _no_curl_err(); }
|
||||
el_val_t http_post_json_with_headers(el_val_t url, el_val_t h, el_val_t b) { (void)url; (void)h; (void)b; return _no_curl_err(); }
|
||||
el_val_t http_post_form_auth(el_val_t url, el_val_t b, el_val_t a) { (void)url; (void)b; (void)a; return _no_curl_err(); }
|
||||
el_val_t http_delete(el_val_t url) { (void)url; return _no_curl_err(); }
|
||||
el_val_t http_patch(el_val_t url, el_val_t body) { (void)url; (void)body; return _no_curl_err(); }
|
||||
@@ -11202,4 +11445,8 @@ el_val_t llm_call_agentic(el_val_t m, el_val_t s, el_val_t u, el_val_t t) { (voi
|
||||
el_val_t llm_vision(el_val_t m, el_val_t s, el_val_t p, el_val_t i) { (void)m; (void)s; (void)p; (void)i; return _no_curl_err(); }
|
||||
el_val_t llm_models(void) { return el_list_empty(); }
|
||||
void llm_register_tool(el_val_t n, el_val_t f) { (void)n; (void)f; }
|
||||
/* __ HTTP stubs (no-curl build) */
|
||||
el_val_t __http_do(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
|
||||
el_val_t __http_do_map(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
|
||||
el_val_t __http_do_map_to_file(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t p) { (void)m; (void)u; (void)b; (void)h; (void)p; return _no_curl_err(); }
|
||||
#endif /* !HAVE_CURL */
|
||||
|
||||
@@ -81,8 +81,8 @@ extern "C" {
|
||||
|
||||
/* ── I/O ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
void println(el_val_t s);
|
||||
void print(el_val_t s);
|
||||
el_val_t println(el_val_t s);
|
||||
el_val_t print(el_val_t s);
|
||||
el_val_t readline(void);
|
||||
|
||||
/* ── String builtins ─────────────────────────────────────────────────────── */
|
||||
@@ -95,6 +95,7 @@ el_val_t str_len(el_val_t s);
|
||||
el_val_t str_concat(el_val_t a, el_val_t b);
|
||||
el_val_t int_to_str(el_val_t n);
|
||||
el_val_t str_to_int(el_val_t s);
|
||||
el_val_t native_str_to_int(el_val_t s);
|
||||
el_val_t str_slice(el_val_t s, el_val_t start, el_val_t end);
|
||||
el_val_t str_contains(el_val_t s, el_val_t sub);
|
||||
el_val_t str_replace(el_val_t s, el_val_t from, el_val_t to);
|
||||
@@ -149,10 +150,11 @@ el_val_t http_post(el_val_t url, el_val_t body);
|
||||
el_val_t http_post_json(el_val_t url, el_val_t json_body);
|
||||
el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map);
|
||||
el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map);
|
||||
el_val_t http_post_json_with_headers(el_val_t url, el_val_t headers_map, el_val_t json_body);
|
||||
el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header);
|
||||
el_val_t http_delete(el_val_t url);
|
||||
void http_serve(el_val_t port, el_val_t handler);
|
||||
void http_set_handler(el_val_t name);
|
||||
el_val_t http_serve(el_val_t port, el_val_t handler);
|
||||
el_val_t http_set_handler(el_val_t name);
|
||||
|
||||
/* HTTP server v2 ─────────────────────────────────────────────────────────────
|
||||
* Same dispatch model as http_serve, but the handler signature is widened:
|
||||
@@ -173,8 +175,8 @@ void http_set_handler(el_val_t name);
|
||||
* The 3-arg http_serve(port, handler) remains supported unchanged for
|
||||
* existing handlers (e.g. products/web/server.el): it dispatches with
|
||||
* (method, path, body), hardcodes 200 OK, and auto-detects content type. */
|
||||
void http_serve_v2(el_val_t port, el_val_t handler);
|
||||
void http_set_handler_v2(el_val_t name);
|
||||
el_val_t http_serve_v2(el_val_t port, el_val_t handler);
|
||||
el_val_t http_set_handler_v2(el_val_t name);
|
||||
|
||||
/* Build an HTTP response envelope. `headers_json` should be a JSON object
|
||||
* literal like `{"WWW-Authenticate":"Basic"}` (or "" / "{}" for none). The
|
||||
@@ -526,7 +528,7 @@ el_val_t parse_int(el_val_t s, el_val_t default_val);
|
||||
|
||||
/* ── Process ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
void exit_program(el_val_t code);
|
||||
el_val_t exit_program(el_val_t code);
|
||||
el_val_t getpid_now(void);
|
||||
|
||||
/* ── CGI identity ─────────────────────────────────────────────────────────────
|
||||
@@ -779,6 +781,95 @@ el_val_t emit_event(el_val_t name, el_val_t duration_ms);
|
||||
el_val_t __thread_create(el_val_t fn_name_v, el_val_t arg_v);
|
||||
el_val_t __thread_join(el_val_t tid_v);
|
||||
|
||||
/* ── __ prefixed aliases (self-hosting compiler ABI) ─────────────────────────
|
||||
* The El self-hosting compiler emits calls to __-prefixed names. These are
|
||||
* forwarding wrappers around the existing el_runtime functions above. */
|
||||
|
||||
/* I/O */
|
||||
el_val_t __println(el_val_t s);
|
||||
el_val_t __print(el_val_t s);
|
||||
el_val_t __readline(void);
|
||||
|
||||
/* String */
|
||||
el_val_t __int_to_str(el_val_t n);
|
||||
el_val_t __str_to_int(el_val_t s);
|
||||
el_val_t __float_to_str(el_val_t f);
|
||||
el_val_t __str_to_float(el_val_t s);
|
||||
el_val_t __str_len(el_val_t s);
|
||||
el_val_t __str_char_at(el_val_t s, el_val_t i);
|
||||
el_val_t __str_cmp(el_val_t a, el_val_t b);
|
||||
el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n);
|
||||
el_val_t __str_concat_raw(el_val_t a, el_val_t b);
|
||||
el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end);
|
||||
el_val_t __str_alloc(el_val_t n);
|
||||
el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c);
|
||||
|
||||
/* URL encoding */
|
||||
el_val_t __url_encode(el_val_t s);
|
||||
el_val_t __url_decode(el_val_t s);
|
||||
|
||||
/* Environment */
|
||||
el_val_t __env_get(el_val_t key);
|
||||
|
||||
/* Subprocess */
|
||||
el_val_t __exec(el_val_t cmd);
|
||||
el_val_t __exec_bg(el_val_t cmd);
|
||||
|
||||
/* Process */
|
||||
el_val_t __exit_program(el_val_t code);
|
||||
|
||||
/* Filesystem */
|
||||
el_val_t __fs_exists(el_val_t path);
|
||||
el_val_t __fs_mkdir(el_val_t path);
|
||||
el_val_t __fs_read(el_val_t path);
|
||||
el_val_t __fs_write(el_val_t path, el_val_t content);
|
||||
el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n);
|
||||
el_val_t __fs_list_raw(el_val_t path);
|
||||
|
||||
/* HTTP server */
|
||||
el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body);
|
||||
el_val_t __http_serve(el_val_t port, el_val_t handler);
|
||||
el_val_t __http_serve_v2(el_val_t port, el_val_t handler);
|
||||
|
||||
/* HTTP conn fd / SSE (weak; overridden by el_seed.c when linked together) */
|
||||
el_val_t __http_conn_fd(void);
|
||||
el_val_t __http_sse_open(el_val_t conn_id);
|
||||
el_val_t __http_sse_send(el_val_t conn_id, el_val_t data);
|
||||
el_val_t __http_sse_close(el_val_t conn_id);
|
||||
|
||||
/* HTTP client (requires HAVE_CURL; stubs provided for no-curl builds) */
|
||||
el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_map, el_val_t timeout_ms);
|
||||
el_val_t __http_do_map(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t timeout_ms);
|
||||
el_val_t __http_do_map_to_file(el_val_t method, el_val_t url, el_val_t body,
|
||||
el_val_t headers_json, el_val_t output_path);
|
||||
|
||||
/* JSON */
|
||||
el_val_t __json_array_get(el_val_t json, el_val_t index);
|
||||
el_val_t __json_array_get_string(el_val_t json, el_val_t index);
|
||||
el_val_t __json_array_len(el_val_t json);
|
||||
el_val_t __json_get(el_val_t json, el_val_t key);
|
||||
el_val_t __json_get_raw(el_val_t json, el_val_t key);
|
||||
el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value);
|
||||
el_val_t __json_parse_map(el_val_t json_str);
|
||||
el_val_t __json_stringify_val(el_val_t val);
|
||||
|
||||
/* Hashing */
|
||||
el_val_t __sha256_hex(el_val_t s);
|
||||
|
||||
/* State K/V */
|
||||
el_val_t __state_del(el_val_t key);
|
||||
el_val_t __state_get(el_val_t key);
|
||||
el_val_t __state_keys(void);
|
||||
el_val_t __state_set(el_val_t key, el_val_t val);
|
||||
|
||||
/* UUID */
|
||||
el_val_t __uuid_v4(void);
|
||||
|
||||
/* Args */
|
||||
el_val_t __args_json(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -990,6 +990,8 @@ el_val_t __json_get(el_val_t json, el_val_t key) { return j
|
||||
el_val_t __json_get_raw(el_val_t json_str, el_val_t key) { return json_get_raw(json_str, key); }
|
||||
el_val_t __json_parse(el_val_t s) { return json_parse(s); }
|
||||
el_val_t __json_stringify(el_val_t v) { return json_stringify(v); }
|
||||
el_val_t __json_parse_map(el_val_t json_str) { return json_parse(json_str); }
|
||||
el_val_t __json_stringify_val(el_val_t val) { return json_stringify(val); }
|
||||
el_val_t __json_array_len(el_val_t json_str) { return json_array_len(json_str); }
|
||||
el_val_t __json_array_get(el_val_t json_str, el_val_t index) { return json_array_get(json_str, index); }
|
||||
el_val_t __json_array_get_string(el_val_t json_str, el_val_t index) { return json_array_get_string(json_str, index); }
|
||||
|
||||
@@ -174,6 +174,8 @@ el_val_t __json_get(el_val_t json, el_val_t key);
|
||||
el_val_t __json_get_raw(el_val_t json_str, el_val_t key);
|
||||
el_val_t __json_parse(el_val_t s);
|
||||
el_val_t __json_stringify(el_val_t v);
|
||||
el_val_t __json_parse_map(el_val_t json_str); /* alias for __json_parse */
|
||||
el_val_t __json_stringify_val(el_val_t val); /* alias for __json_stringify */
|
||||
el_val_t __json_array_len(el_val_t json_str);
|
||||
el_val_t __json_array_get(el_val_t json_str, el_val_t index);
|
||||
el_val_t __json_array_get_string(el_val_t json_str, el_val_t index);
|
||||
|
||||
+5
-2
@@ -271,7 +271,10 @@ fn link_binary(c_files: [String], out_bin: String, runtime_path: String, out_dir
|
||||
let parts: [String] = native_list_empty()
|
||||
// Include both the runtime dir (for el_runtime.h) and the output dir
|
||||
// (for module.elh cross-module forward declarations).
|
||||
let parts = native_list_append(parts, "cc -O2 -fbracket-depth=1024 -I " + dirname_of(runtime_path) + " -I " + out_dir)
|
||||
// Detect clang vs gcc: -fbracket-depth is clang-only; silently ignored
|
||||
// if unsupported but gcc rejects it with an error.
|
||||
let bracket_flag: String = "$(cc --version 2>&1 | grep -q clang && printf -- '-fbracket-depth=1024' || true)"
|
||||
let parts = native_list_append(parts, "cc -O2 " + bracket_flag + " -I " + dirname_of(runtime_path) + " -I " + out_dir)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let f: String = native_list_get(c_files, i)
|
||||
@@ -279,7 +282,7 @@ fn link_binary(c_files: [String], out_bin: String, runtime_path: String, out_dir
|
||||
let i = i + 1
|
||||
}
|
||||
let parts = native_list_append(parts, runtime_path)
|
||||
let parts = native_list_append(parts, "-lcurl -lpthread -lm")
|
||||
let parts = native_list_append(parts, "-lcurl -lssl -lcrypto -lpthread -lm")
|
||||
let parts = native_list_append(parts, "-o " + out_bin)
|
||||
let cmd: String = str_join(parts, " ")
|
||||
println(" link " + out_bin)
|
||||
|
||||
@@ -44,7 +44,7 @@ run_runtime_case() {
|
||||
fi
|
||||
|
||||
if ! cc -O2 -I "${RUNTIME_DIR}" "${out_c}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o "${out_bin}" 2>/tmp/cal_test.cc.err; then
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o "${out_bin}" 2>/tmp/cal_test.cc.err; then
|
||||
echo "FAIL ${name} — cc failed:"
|
||||
cat /tmp/cal_test.cc.err | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
|
||||
@@ -26,7 +26,7 @@ echo "==> Compiling runner.el via ${ELC}"
|
||||
|
||||
echo "==> Linking against ${RUNTIME_DIR}/el_runtime.c"
|
||||
cc -O2 -I "${RUNTIME_DIR}" "${OUT_C}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o "${OUT_BIN}"
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o "${OUT_BIN}"
|
||||
|
||||
echo "==> Running"
|
||||
"${OUT_BIN}"
|
||||
|
||||
@@ -42,7 +42,7 @@ run_runtime_case() {
|
||||
fi
|
||||
|
||||
if ! cc -O2 -I "${RUNTIME_DIR}" "${out_c}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o "${out_bin}" 2>/tmp/text_test.cc.err; then
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o "${out_bin}" 2>/tmp/text_test.cc.err; then
|
||||
echo "FAIL ${name} — cc failed:"
|
||||
cat /tmp/text_test.cc.err | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
|
||||
@@ -55,7 +55,7 @@ run_runtime_case() {
|
||||
fi
|
||||
|
||||
if ! cc -O2 -I "${RUNTIME_DIR}" "${out_c}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o "${out_bin}" 2>/tmp/time_test.cc.err; then
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o "${out_bin}" 2>/tmp/time_test.cc.err; then
|
||||
echo "FAIL ${name} — cc failed:"
|
||||
cat /tmp/time_test.cc.err | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
@@ -116,7 +116,7 @@ run_typeerror_case() {
|
||||
fi
|
||||
|
||||
if cc -O2 -I "${RUNTIME_DIR}" "${out_c}" "${RUNTIME_DIR}/el_runtime.c" \
|
||||
-lcurl -lpthread -o /tmp/time_test_should_not_link 2>/tmp/time_test.cc.err; then
|
||||
-lcurl -lssl -lcrypto -lpthread -lm -o /tmp/time_test_should_not_link 2>/tmp/time_test.cc.err; then
|
||||
echo "FAIL ${name} — cc unexpectedly succeeded; type rule did not fire"
|
||||
FAIL=$((FAIL+1))
|
||||
FAILED_NAMES+=("${name}")
|
||||
|
||||
@@ -10,15 +10,6 @@
|
||||
// export PATH="$HOME/.el/bin:$PATH"
|
||||
// export EL_HOME="$HOME/.el"
|
||||
|
||||
// ── Imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import "../../runtime/string.el"
|
||||
import "../../runtime/env.el"
|
||||
import "../../runtime/fs.el"
|
||||
import "../../runtime/exec.el"
|
||||
import "../../runtime/json.el"
|
||||
import "../../runtime/http.el"
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn gitea_releases_url() -> String {
|
||||
|
||||
Generated
-897
@@ -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"
|
||||
@@ -1,18 +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",
|
||||
"vessels/el-html",
|
||||
"examples/profile-card",
|
||||
]
|
||||
resolver = "2"
|
||||
@@ -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 el.toml / 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("el.toml 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.");
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms);
|
||||
assert!(aspect.is_some(), "should create authorize aspect");
|
||||
assert_eq!(aspect.unwrap().name(), "authorize");
|
||||
}
|
||||
|
||||
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
|
||||
#[test]
|
||||
fn test_registry_unknown_aspect() {
|
||||
let registry = AspectRegistry::with_builtins();
|
||||
let result = registry.create("unknown_aspect", &HashMap::new());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
|
||||
#[test]
|
||||
fn test_cache_zero_ttl() {
|
||||
let aspect = CacheAspect::new(0); // Immediate expiry
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let nc = n.clone();
|
||||
// Both calls should hit proceed since ttl=0 means instant expiry
|
||||
let p1: crate::ProceedFn = Box::new(move |_ctx| {
|
||||
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(InvocationResult::new(v.to_string()))
|
||||
});
|
||||
// Zero TTL will expire immediately; we just verify it doesn't panic
|
||||
let _ = aspect.around(ctx.clone(), &p1);
|
||||
// Second call should also execute proceed
|
||||
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
|
||||
let r = aspect.around(ctx, &p2).unwrap();
|
||||
assert_eq!(r.value, "fresh");
|
||||
}
|
||||
|
||||
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
|
||||
#[test]
|
||||
fn test_empty_chain_calls_proceed() {
|
||||
let chain = AspectChain::new();
|
||||
assert!(chain.is_empty());
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
|
||||
assert_eq!(result.value, "direct");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 `el.toml`).
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
//! Tests for el-auth.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
context::{AuthContext, AuthUser},
|
||||
jwt::{JwtClaims, JwtProvider},
|
||||
middleware::AuthMiddleware,
|
||||
roles::{Permission, Role, RoleRegistry},
|
||||
session::SessionProvider,
|
||||
AuthError, AuthProvider,
|
||||
};
|
||||
|
||||
fn test_user() -> AuthUser {
|
||||
AuthUser::new("user-1", "alice@example.com", "Alice")
|
||||
}
|
||||
|
||||
fn test_provider() -> JwtProvider {
|
||||
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
|
||||
.with_ttl(3600)
|
||||
}
|
||||
|
||||
fn registry() -> RoleRegistry {
|
||||
let mut r = RoleRegistry::new();
|
||||
r.register(
|
||||
Role::new("admin")
|
||||
.with_permissions(vec!["read", "write", "delete"]),
|
||||
);
|
||||
r.register(
|
||||
Role::new("user")
|
||||
.with_permissions(vec!["read"]),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_sign_and_verify() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["user"]);
|
||||
}
|
||||
|
||||
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_invalid_signature() {
|
||||
let provider = test_provider();
|
||||
let other_provider = JwtProvider::new(b"different-secret".to_vec());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec![], 3600);
|
||||
let token = other_provider.encode(&claims);
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 3: JWT expired token is rejected ────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_expired_token() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
// TTL of 0 — expires immediately
|
||||
let claims = JwtClaims::new(&user, vec![], 0);
|
||||
let token = provider.encode(&claims);
|
||||
// Wait a moment (in tests, just check the claims are_expired)
|
||||
assert!(claims.is_expired() || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
true
|
||||
});
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
|
||||
#[test]
|
||||
fn test_jwt_verify_returns_auth_context() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let reg = registry();
|
||||
let token = provider.issue(user, ®).unwrap();
|
||||
let ctx = provider.verify(&token).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_role() {
|
||||
let ctx = AuthContext::authenticated(
|
||||
test_user(),
|
||||
vec!["admin".into(), "user".into()],
|
||||
"tok",
|
||||
);
|
||||
assert!(ctx.has_role("admin"));
|
||||
assert!(ctx.has_role("user"));
|
||||
assert!(!ctx.has_role("superadmin"));
|
||||
}
|
||||
|
||||
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_permission() {
|
||||
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
|
||||
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
|
||||
assert!(ctx.has_permission("read"));
|
||||
assert!(ctx.has_permission("delete"));
|
||||
assert!(!ctx.has_permission("sudo"));
|
||||
}
|
||||
|
||||
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
|
||||
#[test]
|
||||
fn test_anonymous_context() {
|
||||
let ctx = AuthContext::anonymous();
|
||||
assert!(!ctx.is_authenticated());
|
||||
assert!(ctx.user_id().is_none());
|
||||
}
|
||||
|
||||
// ── Test 8: Role has_permission ──────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_role_has_permission() {
|
||||
let role = Role::new("editor")
|
||||
.with_permissions(vec!["read", "write"]);
|
||||
assert!(role.has_permission("read"));
|
||||
assert!(role.has_permission("write"));
|
||||
assert!(!role.has_permission("delete"));
|
||||
}
|
||||
|
||||
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
|
||||
#[test]
|
||||
fn test_role_registry_permission_check() {
|
||||
let reg = registry();
|
||||
let roles = vec!["user".to_string()];
|
||||
assert!(reg.has_permission(&roles, "read"));
|
||||
assert!(!reg.has_permission(&roles, "delete"));
|
||||
|
||||
let admin_roles = vec!["admin".to_string()];
|
||||
assert!(reg.has_permission(&admin_roles, "delete"));
|
||||
}
|
||||
|
||||
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
|
||||
#[test]
|
||||
fn test_session_issue_and_verify() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
let ctx = provider.verify(&session_id).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 11: SessionProvider revoke removes session ───────────────────────
|
||||
#[test]
|
||||
fn test_session_revoke() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
provider.revoke(&session_id).unwrap();
|
||||
let result = provider.verify(&session_id);
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 12: SessionProvider unknown session returns error ────────────────
|
||||
#[test]
|
||||
fn test_session_unknown() {
|
||||
let provider = SessionProvider::new();
|
||||
let result = provider.verify("nonexistent-session-id");
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
|
||||
#[test]
|
||||
fn test_middleware_extracts_bearer_token() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let header = format!("Bearer {}", token);
|
||||
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
|
||||
#[test]
|
||||
fn test_middleware_no_header_anonymous() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let ctx = middleware.authenticate_from_header(None).unwrap();
|
||||
assert!(!ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 15: Permission Display ───────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_permission_display() {
|
||||
let perm = Permission::new("write");
|
||||
assert_eq!(perm.to_string(), "write");
|
||||
assert_eq!(perm.as_str(), "write");
|
||||
}
|
||||
|
||||
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
|
||||
#[test]
|
||||
fn test_jwt_claims_json_round_trip() {
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let json = claims.to_json();
|
||||
let decoded = JwtClaims::from_json(&json).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_multiple_roles() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.roles.len(), 2);
|
||||
assert!(decoded.roles.contains(&"admin".to_string()));
|
||||
}
|
||||
|
||||
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
|
||||
#[test]
|
||||
fn test_role_registry_names() {
|
||||
let reg = registry();
|
||||
let mut names = reg.role_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
|
||||
#[test]
|
||||
fn test_session_ttl_config() {
|
||||
let provider = SessionProvider::new().with_ttl(7200);
|
||||
assert_eq!(provider.ttl.as_secs(), 7200);
|
||||
}
|
||||
|
||||
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
|
||||
#[test]
|
||||
fn test_middleware_provider_name() {
|
||||
let jwt_provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(jwt_provider);
|
||||
assert_eq!(middleware.provider_name(), "jwt");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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. el.toml [env.<current>] section
|
||||
/// 4. el.toml [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 `el.toml` 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("el.toml", 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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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. `el.toml` `[env.<current>]` section
|
||||
//! 4. `el.toml` `[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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" => ("zł", false, 2),
|
||||
"TRY" => ("₺", true, 2),
|
||||
"SAR" => ("﷼", false, 2),
|
||||
"AED" => ("د.إ", false, 2),
|
||||
_ => ("¤", true, 2), // generic currency sign for unknown codes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_number_en_us() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_de() {
|
||||
let locale = Locale::new("de-DE");
|
||||
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_no_decimals() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(42.0, &locale, 0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_negative() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_usd() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_jpy_no_decimals() {
|
||||
let locale = Locale::ja();
|
||||
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_groups() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(1000000, &locale), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_small() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(42, &locale), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_whole() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.85, &locale, 0), "85%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_with_decimal() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/// Plural forms — handles language-specific plurality rules.
|
||||
///
|
||||
/// Different languages have very different plural forms. English has two
|
||||
/// (one / other). Russian has four. Arabic has six. This module maps counts
|
||||
/// to the correct form for a given locale.
|
||||
|
||||
/// Named plural categories per Unicode CLDR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PluralForm {
|
||||
/// Exactly zero (some languages have a special zero form).
|
||||
Zero,
|
||||
/// Exactly one.
|
||||
One,
|
||||
/// Small numbers (e.g. 2–4 in Slavic languages).
|
||||
Two,
|
||||
/// Few (language-specific).
|
||||
Few,
|
||||
/// Many (language-specific).
|
||||
Many,
|
||||
/// The catch-all / default.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Determine the plural form for a count in a given language.
|
||||
///
|
||||
/// Rules are a simplified implementation of CLDR plural rules for the most
|
||||
/// common languages. The full CLDR spec covers hundreds of languages; these
|
||||
/// cover the major cases.
|
||||
pub fn plural_form(language: &str, count: i64) -> PluralForm {
|
||||
let n = count.unsigned_abs(); // absolute value for matching
|
||||
|
||||
match language {
|
||||
// English and most Western European languages: one / other
|
||||
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
|
||||
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
|
||||
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// French: one for 0 and 1, other for rest
|
||||
"fr" => {
|
||||
if n <= 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// Russian, Ukrainian, Belarusian: complex Slavic rules
|
||||
"ru" | "uk" | "be" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n10 == 1 && n100 != 11 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Polish: similar Slavic rules
|
||||
"pl" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Czech, Slovak
|
||||
"cs" | "sk" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Arabic: 6 forms
|
||||
"ar" => {
|
||||
let n100 = n % 100;
|
||||
if n == 0 {
|
||||
PluralForm::Zero
|
||||
} else if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if (3..=10).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else if (11..=99).contains(&n100) {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Hebrew
|
||||
"he" | "iw" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if n >= 11 && n % 10 == 0 {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Default: one / other
|
||||
_ => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_one() {
|
||||
assert_eq!(plural_form("en", 1), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_other() {
|
||||
assert_eq!(plural_form("en", 0), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 2), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 100), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_zero_is_one() {
|
||||
assert_eq!(plural_form("fr", 0), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 2), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_one() {
|
||||
assert_eq!(plural_form("ru", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 21), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 101), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_few() {
|
||||
assert_eq!(plural_form("ru", 2), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 3), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 22), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_many() {
|
||||
assert_eq!(plural_form("ru", 5), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 11), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 20), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_zero() {
|
||||
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_two() {
|
||||
assert_eq!(plural_form("ar", 2), PluralForm::Two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_few() {
|
||||
assert_eq!(plural_form("ar", 5), PluralForm::Few);
|
||||
assert_eq!(plural_form("ar", 10), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_many() {
|
||||
assert_eq!(plural_form("ar", 15), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_two() {
|
||||
assert_eq!(plural_form("he", 2), PluralForm::Two);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "profile"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppleOAuth ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apple Sign In OAuth provider.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://appleid.apple.com/auth/authorize`
|
||||
/// - Token: `https://appleid.apple.com/auth/token`
|
||||
///
|
||||
/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key.
|
||||
/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleOAuth {
|
||||
pub client_id: String,
|
||||
/// The JWT client secret (pre-generated, rotated manually or via automation).
|
||||
pub client_secret_jwt: String,
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
impl AppleOAuth {
|
||||
pub fn new(
|
||||
client_id: impl Into<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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "name"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── GithubOAuth ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// GitHub OAuth 2.0 provider.
|
||||
///
|
||||
/// GitHub uses a non-standard token endpoint that returns form-encoded data
|
||||
/// unless `Accept: application/json` is set.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://github.com/login/oauth/authorize`
|
||||
/// - Token: `https://github.com/login/oauth/access_token`
|
||||
///
|
||||
/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubOAuth {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
impl GithubOAuth {
|
||||
pub fn new(client_id: impl Into<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, ¶ms)?;
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 `el.toml`) 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
|
||||
}
|
||||
}
|
||||
@@ -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("<script>"), "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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Platform configuration — parsed from `el.toml`.
|
||||
|
||||
/// 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 `el.toml`.
|
||||
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 `el.toml`.
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -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 `el.toml`:
|
||||
//!
|
||||
//! ```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 `el.toml` 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()),
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn html_escape_attr(s: &str) -> String {
|
||||
html_escape(s).replace('"', """)
|
||||
}
|
||||
|
||||
fn is_void_element(tag: &str) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
|
||||
| "link" | "meta" | "param" | "source" | "track" | "wbr"
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 el.toml".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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `el.toml`.
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -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 el.toml".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))
|
||||
}
|
||||
}
|
||||
@@ -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 `el.toml`:
|
||||
//! ```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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 el.toml (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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Service binding implementations.
|
||||
//!
|
||||
//! Each binding protocol implements the `Binding` trait.
|
||||
//! The proxy uses whatever binding is configured in `el.toml`.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user