el-ui v2: universal platform, service bindings, AOP, auth, publish pipeline

This commit is contained in:
Will Anderson
2026-04-27 19:52:29 -05:00
parent 3bf3c02854
commit 69d1085d2d
51 changed files with 6653 additions and 1 deletions
Generated
+143
View File
@@ -2,6 +2,95 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[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 = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[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",
"hmac",
"sha2",
"thiserror",
]
[[package]]
name = "el-platform"
version = "0.1.0"
dependencies = [
"thiserror",
]
[[package]]
name = "el-publish"
version = "0.1.0"
dependencies = [
"thiserror",
]
[[package]]
name = "el-services"
version = "0.1.0"
dependencies = [
"thiserror",
]
[[package]]
name = "el-ui-compiler"
version = "0.1.0"
@@ -9,6 +98,31 @@ dependencies = [
"thiserror",
]
[[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 = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -27,6 +141,23 @@ dependencies = [
"proc-macro2",
]
[[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 = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -58,8 +189,20 @@ dependencies = [
"syn",
]
[[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 = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+5
View File
@@ -1,5 +1,10 @@
[workspace]
members = [
"crates/el-ui-compiler",
"crates/el-platform",
"crates/el-services",
"crates/el-aop",
"crates/el-auth",
"crates/el-publish",
]
resolver = "2"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-aop"
version = "0.1.0"
edition = "2021"
description = "el-ui aspect-oriented programming — cross-cutting concerns as first-class features"
license = "MIT"
[lib]
name = "el_aop"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+490
View File
@@ -0,0 +1,490 @@
//! 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
})
}
}
+99
View File
@@ -0,0 +1,99 @@
//! 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
//! ```
use crate::{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()
}
}
impl Default for AspectChain {
fn default() -> Self {
Self::new()
}
}
+146
View File
@@ -0,0 +1,146 @@
//! 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
//! @authorize(role: "admin")
//! @cache(ttl: 300)
//! @rate_limit(requests: 100, per: 60)
//! component AdminDashboard { ... }
//! ```
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 registry::AspectRegistry;
#[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)
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 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`).
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>>,
}
impl AspectRegistry {
pub fn new() -> Self {
Self {
factories: RwLock::new(HashMap::new()),
}
}
/// Create a registry with all built-in aspects registered.
pub fn with_builtins() -> Self {
let registry = Self::new();
registry.register_builtins();
registry
}
/// 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()
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Tests for el-aop.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use crate::{
aspects::*,
chain::AspectChain,
registry::AspectRegistry,
AopError, Aspect, InvocationContext, InvocationResult,
};
fn succeed_proceed(value: impl Into<String> + Clone) -> crate::ProceedFn {
let v = value.into();
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
}
fn fail_proceed(msg: impl Into<String> + Clone) -> crate::ProceedFn {
let m = msg.into();
Box::new(move |_ctx| Err(AopError::Aspect(m.clone())))
}
fn ctx(target: &str, method: &str) -> InvocationContext {
InvocationContext::new(target, method)
}
fn authed_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method).with_meta("user_id", "user-123")
}
fn admin_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method)
.with_meta("user_id", "admin-1")
.with_meta("roles", "admin,user")
}
// ── Test 1: AuthenticateAspect rejects unauthenticated calls ─────────────
#[test]
fn test_authenticate_rejects_unauthenticated() {
let aspect = AuthenticateAspect;
let mut ctx = ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_err());
assert!(matches!(result, Err(AopError::Unauthenticated)));
}
// ── Test 2: AuthenticateAspect allows authenticated calls ─────────────────
#[test]
fn test_authenticate_allows_authenticated() {
let aspect = AuthenticateAspect;
let mut ctx = authed_ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 3: AuthorizeAspect rejects wrong role ────────────────────────────
#[test]
fn test_authorize_rejects_wrong_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::Forbidden { .. })));
}
// ── Test 4: AuthorizeAspect allows correct role ───────────────────────────
#[test]
fn test_authorize_allows_correct_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = admin_ctx("Dashboard", "delete");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 5: CacheAspect returns cached result on second call ──────────────
#[test]
fn test_cache_returns_cached_result() {
let aspect = CacheAspect::new(300);
let ctx = authed_ctx("OrderService", "get_orders");
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let cc = call_count.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
Ok(InvocationResult::new(format!("result-{}", n)))
});
// First call — executes proceed
let r1 = aspect.around(ctx.clone(), &proceed).unwrap();
// Second call — should return cached (proceed not called again)
let proceed2: crate::ProceedFn = Box::new(|_ctx| {
panic!("proceed should not be called on cache hit");
});
let r2 = aspect.around(ctx.clone(), &proceed2).unwrap();
assert_eq!(r1.value, r2.value, "cached value should be returned");
}
// ── Test 6: RateLimitAspect blocks after limit exceeded ───────────────────
#[test]
fn test_rate_limit_blocks_after_limit() {
let aspect = RateLimitAspect::new(2, 60);
let mut ctx = authed_ctx("OrderService", "create_order");
// First two calls succeed
assert!(aspect.before(&mut ctx).is_ok());
assert!(aspect.before(&mut ctx).is_ok());
// Third call should be blocked
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::RateLimited { .. })));
}
// ── Test 7: LogAspect passes through to proceed ───────────────────────────
#[test]
fn test_log_aspect_passthrough() {
let aspect = LogAspect::new("info");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap();
assert_eq!(result.value, "user-data");
}
// ── Test 8: ValidateAspect rejects required field missing ────────────────
#[test]
fn test_validate_required_field() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "name", "required");
let mut ctx = authed_ctx("UserService", "create_user");
// No "name" arg
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 9: ValidateAspect passes when field is present ──────────────────
#[test]
fn test_validate_required_field_present() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "alice@example.com");
assert!(aspect.before(&mut ctx).is_ok());
}
// ── Test 10: ValidateAspect rejects invalid email ─────────────────────────
#[test]
fn test_validate_email_rule() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "not-an-email");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 11: RetryAspect retries on failure ───────────────────────────────
#[test]
fn test_retry_succeeds_on_third_attempt() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let attempt_clone = attempt.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n < 2 {
Err(AopError::Aspect("transient error".into()))
} else {
Ok(InvocationResult::new("success"))
}
});
let result = aspect.around(ctx, &proceed).unwrap();
assert_eq!(result.value, "success");
}
// ── Test 12: RetryAspect exhausts all attempts ───────────────────────────
#[test]
fn test_retry_exhausted() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let result = aspect.around(ctx, &fail_proceed("always fails"));
assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. })));
}
// ── Test 13: TraceAspect injects trace/span IDs ───────────────────────────
#[test]
fn test_trace_aspect_injects_ids() {
let aspect = TraceAspect::new("el-ui");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("data")).unwrap();
assert!(
result.metadata.contains_key("trace_id"),
"should inject trace_id"
);
assert!(
result.metadata.contains_key("span_id"),
"should inject span_id"
);
}
// ── Test 14: AspectChain executes aspects in order ────────────────────────
#[test]
fn test_aspect_chain_ordering() {
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
struct OrderTracker {
name: &'static str,
order: Arc<std::sync::Mutex<Vec<&'static str>>>,
}
impl Aspect for OrderTracker {
fn name(&self) -> &'static str { self.name }
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
self.order.lock().unwrap().push(self.name);
proceed(ctx)
}
}
let chain = AspectChain::new()
.add(Arc::new(OrderTracker { name: "first", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "second", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "third", order: order.clone() }));
let ctx = authed_ctx("MyService", "my_method");
chain.execute(ctx, succeed_proceed("ok")).unwrap();
let recorded = order.lock().unwrap();
assert_eq!(*recorded, vec!["first", "second", "third"]);
}
// ── Test 15: AspectChain with auth + authorize rejects unauthenticated ────
#[test]
fn test_aspect_chain_auth_flow() {
let chain = AspectChain::new()
.add(Arc::new(AuthenticateAspect))
.add(Arc::new(AuthorizeAspect::new("admin")));
// Unauthenticated — should fail at authenticate
let ctx = ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Unauthenticated)));
// Authenticated but wrong role — should fail at authorize
let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Forbidden { .. })));
// Admin — should succeed
let ctx = admin_ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(result.is_ok());
}
// ── Test 16: AspectRegistry registers all builtins ────────────────────────
#[test]
fn test_registry_has_builtins() {
let registry = AspectRegistry::with_builtins();
for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] {
assert!(registry.contains(name), "should have built-in: {}", name);
}
}
// ── Test 17: AspectRegistry creates aspects from params ───────────────────
#[test]
fn test_registry_creates_aspect() {
let registry = AspectRegistry::with_builtins();
let mut params = HashMap::new();
params.insert("role".into(), "admin".into());
let aspect = registry.create("authorize", &params);
assert!(aspect.is_some(), "should create authorize aspect");
assert_eq!(aspect.unwrap().name(), "authorize");
}
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
#[test]
fn test_registry_unknown_aspect() {
let registry = AspectRegistry::with_builtins();
let result = registry.create("unknown_aspect", &HashMap::new());
assert!(result.is_none());
}
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
#[test]
fn test_cache_zero_ttl() {
let aspect = CacheAspect::new(0); // Immediate expiry
let ctx = authed_ctx("Service", "method");
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
let nc = n.clone();
// Both calls should hit proceed since ttl=0 means instant expiry
let p1: crate::ProceedFn = Box::new(move |_ctx| {
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(InvocationResult::new(v.to_string()))
});
// Zero TTL will expire immediately; we just verify it doesn't panic
let _ = aspect.around(ctx.clone(), &p1);
// Second call should also execute proceed
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
let r = aspect.around(ctx, &p2).unwrap();
assert_eq!(r.value, "fresh");
}
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
#[test]
fn test_empty_chain_calls_proceed() {
let chain = AspectChain::new();
assert!(chain.is_empty());
let ctx = authed_ctx("Service", "method");
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
assert_eq!(result.value, "direct");
}
}
+18
View File
@@ -0,0 +1,18 @@
[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"
[dev-dependencies]
+78
View File
@@ -0,0 +1,78 @@
//! 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())
}
}
+287
View File
@@ -0,0 +1,287 @@
//! 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.
#[derive(Debug, Clone)]
pub struct JwtClaims {
pub sub: String, // user ID
pub email: String,
pub name: String,
pub roles: Vec<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,
iat: now,
exp: now + ttl_seconds,
}
}
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(",");
format!(
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}],\"iat\":{},\"exp\":{}}}",
self.sub, self.email, self.name, roles_json, self.iat, self.exp
)
}
/// 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");
Some(Self { sub, email, name, roles, 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()
}
+60
View File
@@ -0,0 +1,60 @@
//! 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"
//! ```
pub mod context;
pub mod jwt;
pub mod middleware;
pub mod roles;
pub mod session;
pub use context::{AuthContext, AuthUser};
pub use jwt::{JwtClaims, JwtProvider};
pub use middleware::AuthMiddleware;
pub use roles::{Permission, Role, RoleRegistry};
pub use session::SessionProvider;
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("invalid credentials")]
InvalidCredentials,
#[error("token expired")]
TokenExpired,
#[error("token invalid: {0}")]
TokenInvalid(String),
#[error("session not found")]
SessionNotFound,
#[error("forbidden: requires permission '{0}'")]
Forbidden(String),
#[error("auth configuration error: {0}")]
Config(String),
}
pub type AuthResult<T> = Result<T, AuthError>;
/// The AuthProvider trait — implemented by JWT, Session, OAuth providers.
pub trait AuthProvider: Send + Sync {
/// The provider name (e.g., "jwt", "session").
fn name(&self) -> &'static str;
/// Verify a token/session string and return the auth context.
fn verify(&self, token: &str) -> AuthResult<AuthContext>;
/// Issue a new token/session for an authenticated user.
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
/// Revoke a token/session (for logout).
fn revoke(&self, token: &str) -> AuthResult<()>;
}
+59
View File
@@ -0,0 +1,59 @@
//! Auth middleware — extracts and verifies auth tokens from requests.
//!
//! In an axum application:
//! ```text
//! let app = Router::new()
//! .route("/api/users", get(list_users))
//! .layer(AuthMiddleware::new(jwt_provider));
//! ```
//!
//! The middleware populates `AuthContext` from the `Authorization` header.
use crate::{AuthContext, AuthProvider, AuthResult};
use std::sync::Arc;
/// Auth middleware — wraps an auth provider to extract context from HTTP headers.
pub struct AuthMiddleware {
provider: Arc<dyn AuthProvider>,
}
impl AuthMiddleware {
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
Self { provider }
}
/// Extract and verify the auth token from an Authorization header value.
///
/// Supported formats:
/// - `Bearer <token>` — JWT or opaque token
/// - `Session <session_id>` — server-side session
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
match authorization {
None => Ok(AuthContext::anonymous()),
Some(header) => {
let token = if let Some(t) = header.strip_prefix("Bearer ") {
t.trim()
} else if let Some(t) = header.strip_prefix("Session ") {
t.trim()
} else {
header.trim()
};
self.provider.verify(token)
}
}
}
/// Authenticate from a query parameter (for WebSocket upgrades where
/// Authorization headers can't be set from JS).
pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult<AuthContext> {
match token {
None => Ok(AuthContext::anonymous()),
Some(t) => self.provider.verify(t),
}
}
/// Get the underlying provider name.
pub fn provider_name(&self) -> &'static str {
self.provider.name()
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Role and permission model.
use std::collections::HashMap;
/// A fine-grained permission (e.g., "read", "write", "delete").
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Permission(pub String);
impl Permission {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A role that grants a set of permissions.
#[derive(Debug, Clone)]
pub struct Role {
pub name: String,
pub permissions: Vec<Permission>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(Permission::new(perm));
self
}
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> Self {
self.permissions.extend(perms.into_iter().map(Permission::new));
self
}
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p.0 == perm)
}
}
/// The registry of all roles in the application.
#[derive(Debug, Default)]
pub struct RoleRegistry {
roles: HashMap<String, Role>,
}
impl RoleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Register a role.
pub fn register(&mut self, role: Role) {
self.roles.insert(role.name.clone(), role);
}
/// Get a role by name.
pub fn get(&self, name: &str) -> Option<&Role> {
self.roles.get(name)
}
/// Check if the given role names grant the given permission.
pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool {
role_names.iter().any(|role_name| {
self.roles
.get(role_name)
.map(|r| r.has_permission(permission))
.unwrap_or(false)
})
}
/// List all registered role names.
pub fn role_names(&self) -> Vec<&str> {
self.roles.keys().map(|s| s.as_str()).collect()
}
}
+105
View File
@@ -0,0 +1,105 @@
//! 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(())
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Tests for el-auth.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::{
context::{AuthContext, AuthUser},
jwt::{JwtClaims, JwtProvider},
middleware::AuthMiddleware,
roles::{Permission, Role, RoleRegistry},
session::SessionProvider,
AuthError, AuthProvider,
};
fn test_user() -> AuthUser {
AuthUser::new("user-1", "alice@example.com", "Alice")
}
fn test_provider() -> JwtProvider {
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
.with_ttl(3600)
}
fn registry() -> RoleRegistry {
let mut r = RoleRegistry::new();
r.register(
Role::new("admin")
.with_permissions(vec!["read", "write", "delete"]),
);
r.register(
Role::new("user")
.with_permissions(vec!["read"]),
);
r
}
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
#[test]
fn test_jwt_sign_and_verify() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["user"]);
}
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
#[test]
fn test_jwt_invalid_signature() {
let provider = test_provider();
let other_provider = JwtProvider::new(b"different-secret".to_vec());
let user = test_user();
let claims = JwtClaims::new(&user, vec![], 3600);
let token = other_provider.encode(&claims);
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
}
// ── Test 3: JWT expired token is rejected ────────────────────────────────
#[test]
fn test_jwt_expired_token() {
let provider = test_provider();
let user = test_user();
// TTL of 0 — expires immediately
let claims = JwtClaims::new(&user, vec![], 0);
let token = provider.encode(&claims);
// Wait a moment (in tests, just check the claims are_expired)
assert!(claims.is_expired() || {
std::thread::sleep(std::time::Duration::from_millis(1100));
true
});
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
}
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
#[test]
fn test_jwt_verify_returns_auth_context() {
let provider = test_provider();
let user = test_user();
let reg = registry();
let token = provider.issue(user, &reg).unwrap();
let ctx = provider.verify(&token).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
#[test]
fn test_auth_context_has_role() {
let ctx = AuthContext::authenticated(
test_user(),
vec!["admin".into(), "user".into()],
"tok",
);
assert!(ctx.has_role("admin"));
assert!(ctx.has_role("user"));
assert!(!ctx.has_role("superadmin"));
}
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
#[test]
fn test_auth_context_has_permission() {
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
assert!(ctx.has_permission("read"));
assert!(ctx.has_permission("delete"));
assert!(!ctx.has_permission("sudo"));
}
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
#[test]
fn test_anonymous_context() {
let ctx = AuthContext::anonymous();
assert!(!ctx.is_authenticated());
assert!(ctx.user_id().is_none());
}
// ── Test 8: Role has_permission ──────────────────────────────────────────
#[test]
fn test_role_has_permission() {
let role = Role::new("editor")
.with_permissions(vec!["read", "write"]);
assert!(role.has_permission("read"));
assert!(role.has_permission("write"));
assert!(!role.has_permission("delete"));
}
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
#[test]
fn test_role_registry_permission_check() {
let reg = registry();
let roles = vec!["user".to_string()];
assert!(reg.has_permission(&roles, "read"));
assert!(!reg.has_permission(&roles, "delete"));
let admin_roles = vec!["admin".to_string()];
assert!(reg.has_permission(&admin_roles, "delete"));
}
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
#[test]
fn test_session_issue_and_verify() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
let ctx = provider.verify(&session_id).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 11: SessionProvider revoke removes session ───────────────────────
#[test]
fn test_session_revoke() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
provider.revoke(&session_id).unwrap();
let result = provider.verify(&session_id);
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 12: SessionProvider unknown session returns error ────────────────
#[test]
fn test_session_unknown() {
let provider = SessionProvider::new();
let result = provider.verify("nonexistent-session-id");
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
#[test]
fn test_middleware_extracts_bearer_token() {
let provider = Arc::new(test_provider());
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let middleware = AuthMiddleware::new(provider);
let header = format!("Bearer {}", token);
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
assert!(ctx.is_authenticated());
}
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
#[test]
fn test_middleware_no_header_anonymous() {
let provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(provider);
let ctx = middleware.authenticate_from_header(None).unwrap();
assert!(!ctx.is_authenticated());
}
// ── Test 15: Permission Display ───────────────────────────────────────────
#[test]
fn test_permission_display() {
let perm = Permission::new("write");
assert_eq!(perm.to_string(), "write");
assert_eq!(perm.as_str(), "write");
}
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
#[test]
fn test_jwt_claims_json_round_trip() {
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let json = claims.to_json();
let decoded = JwtClaims::from_json(&json).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["admin", "user"]);
}
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
#[test]
fn test_jwt_multiple_roles() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.roles.len(), 2);
assert!(decoded.roles.contains(&"admin".to_string()));
}
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
#[test]
fn test_role_registry_names() {
let reg = registry();
let mut names = reg.role_names();
names.sort();
assert_eq!(names, vec!["admin", "user"]);
}
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
#[test]
fn test_session_ttl_config() {
let provider = SessionProvider::new().with_ttl(7200);
assert_eq!(provider.ttl.as_secs(), 7200);
}
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
#[test]
fn test_middleware_provider_name() {
let jwt_provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(jwt_provider);
assert_eq!(middleware.provider_name(), "jwt");
}
}
+15
View File
@@ -0,0 +1,15 @@
[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]
@@ -0,0 +1,169 @@
//! Android backend — NDK + JNI bridge.
//!
//! Architecture: correct and complete. JNI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an Android View:
//! element("div") → LinearLayout / FrameLayout
//! element("span") → TextView (inline)
//! element("button") → Button
//! element("input") → EditText
//! text("...") → TextView
//!
//! Event binding:
//! "click" → setOnClickListener
//! "input" → addTextChangedListener
//! "change" → setOnCheckedChangeListener
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct AndroidBackend;
impl AndroidBackend {
pub fn new() -> Self {
Self
}
fn android_view_class(tag: &str) -> &'static str {
match tag {
"button" => "android.widget.Button",
"input" => "android.widget.EditText",
"textarea" => "android.widget.EditText",
"img" => "android.widget.ImageView",
"ul" | "ol" => "android.widget.ListView",
"li" => "android.view.View",
"nav" => "androidx.appcompat.widget.Toolbar",
"div" | "section" | "main" | "article" => "android.widget.FrameLayout",
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
"android.widget.TextView"
}
_ => "android.view.View",
}
}
fn android_event_listener(event: &str) -> &'static str {
match event {
"click" => "setOnClickListener",
"input" | "change" => "addTextChangedListener",
"focus" => "setOnFocusChangeListener",
_ => "setOnTouchListener",
}
}
}
impl Default for AndroidBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for AndroidBackend {
fn name(&self) -> &'static str {
"android"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: JNI call to create the Android view:
// env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...])
node.attributes.push(crate::Attribute::new(
"data-android-class",
Self::android_view_class(tag),
));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: JNI: create TextView, set text = content
node.attributes
.push(crate::Attribute::new("data-android-class", "android.widget.TextView"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute to Java property setter via JNI
// "class" → setBackground / setTextAppearance
// "disabled" → setEnabled(false)
// "placeholder" → setHint(value)
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: JNI: ((ViewGroup) parent).addView(child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"android: child index {} out of bounds",
child_index
)));
}
// TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"android: replace_child out of bounds".into(),
));
}
// TODO: JNI: remove old view, add new view at index
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let listener = Self::android_event_listener(event);
// TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... })
node.attributes.push(crate::Attribute::new(
format!("data-android-event-{}", event),
listener,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Activity by container_id, set root as content view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply JNI mutations
Ok(())
}
}
+166
View File
@@ -0,0 +1,166 @@
//! iOS backend — UIKit via C FFI / Objective-C bridge.
//!
//! Architecture: correct and complete. Native UIKit calls are stubs marked
//! TODO — a future agent fills in the actual `extern "C"` calls.
//!
//! Each `PlatformNode` maps to a UIKit view:
//! element("div") → UIView
//! element("span") → UILabel (inline)
//! element("button") → UIButton
//! element("input") → UITextField
//! text("...") → UILabel
//!
//! Event binding maps DOM event names to UIControl target-action pairs:
//! "click" → UIControlEventTouchUpInside
//! "input" → UIControlEventEditingChanged
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct IosBackend;
impl IosBackend {
pub fn new() -> Self {
Self
}
/// Map an HTML tag name to the UIKit class name it maps to.
fn uikit_class(tag: &str) -> &'static str {
match tag {
"button" => "UIButton",
"input" => "UITextField",
"textarea" => "UITextView",
"img" => "UIImageView",
"ul" | "ol" => "UITableView",
"li" => "UITableViewCell",
"nav" => "UINavigationBar",
_ => "UIView",
}
}
/// Map a DOM event name to a UIControlEvent constant name.
fn uicontrol_event(event: &str) -> &'static str {
match event {
"click" => "UIControlEventTouchUpInside",
"input" | "change" => "UIControlEventEditingChanged",
"focus" => "UIControlEventEditingDidBegin",
"blur" => "UIControlEventEditingDidEnd",
_ => "UIControlEventAllEvents",
}
}
}
impl Default for IosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for IosBackend {
fn name(&self) -> &'static str {
"ios"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: call UIKit C FFI to allocate the view:
// extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; }
// node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) });
let uikit_class = Self::uikit_class(tag);
node.attributes
.push(crate::Attribute::new("data-uikit-class", uikit_class));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: UILabel with text = content
node.attributes
.push(crate::Attribute::new("data-uikit-class", "UILabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute names to UIKit property setters:
// "class" → apply style from stylesheet
// "disabled" → view.isUserInteractionEnabled = false
// "placeholder" → textField.placeholder = value
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView] via FFI
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"ios: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview] via FFI
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("ios: replace_child out of bounds".into()));
}
// TODO: remove old view, insert new view via FFI
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let uievent = Self::uicontrol_event(event);
// TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent]
node.attributes
.push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
// iOS doesn't render to HTML strings at runtime, but we support it for
// testing and SSR fallback.
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get UIViewController by container_id and set root as its view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff old and new trees, apply UIKit mutations via FFI
Ok(())
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Linux backend — GTK/Wayland.
//!
//! Architecture: correct and complete. GTK calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a GtkWidget:
//! element("div") → GtkBox (vertical)
//! element("button") → GtkButton
//! element("input") → GtkEntry
//! text("...") → GtkLabel
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct LinuxBackend;
impl LinuxBackend {
pub fn new() -> Self {
Self
}
fn gtk_widget(tag: &str) -> &'static str {
match tag {
"button" => "GtkButton",
"input" => "GtkEntry",
"textarea" => "GtkTextView",
"img" => "GtkImage",
"ul" | "ol" => "GtkListBox",
"li" => "GtkListBoxRow",
"nav" => "GtkHeaderBar",
"div" | "section" | "main" | "article" | "span" => "GtkBox",
_ => "GtkWidget",
}
}
fn gtk_signal(event: &str) -> &'static str {
match event {
"click" => "clicked",
"input" | "change" => "changed",
"focus" => "focus-in-event",
"blur" => "focus-out-event",
"keydown" | "keypress" => "key-press-event",
"keyup" => "key-release-event",
_ => "event",
}
}
}
impl Default for LinuxBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for LinuxBackend {
fn name(&self) -> &'static str {
"linux"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI
node.attributes
.push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: gtk_label_new(content)
node.attributes
.push(crate::Attribute::new("data-gtk-widget", "GtkLabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to GTK property setters
// "class" → gtk_widget_add_css_class
// "disabled" → gtk_widget_set_sensitive(false)
// "placeholder" → gtk_entry_set_placeholder_text
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: gtk_box_append(parent, child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"linux: child index {} out of bounds",
child_index
)));
}
// TODO: gtk_widget_unparent(child)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("linux: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let signal = Self::gtk_signal(event);
// TODO: g_signal_connect(widget, signal, callback, data)
node.attributes
.push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get GtkWindow and call gtk_window_set_child(root)
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply GTK mutations
Ok(())
}
}
+137
View File
@@ -0,0 +1,137 @@
//! macOS backend — AppKit bindings.
//!
//! Architecture: correct and complete. AppKit calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an NSView:
//! element("div") → NSView
//! element("button") → NSButton
//! element("input") → NSTextField
//! text("...") → NSTextField (label mode)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct MacosBackend;
impl MacosBackend {
pub fn new() -> Self {
Self
}
fn appkit_class(tag: &str) -> &'static str {
match tag {
"button" => "NSButton",
"input" => "NSTextField",
"textarea" => "NSTextView",
"img" => "NSImageView",
"ul" | "ol" => "NSTableView",
"nav" => "NSToolbar",
_ => "NSView",
}
}
}
impl Default for MacosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for MacosBackend {
fn name(&self) -> &'static str {
"macos"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; }
node.attributes
.push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: NSTextField in label mode with stringValue = content
node.attributes
.push(crate::Attribute::new("data-appkit-class", "NSTextField"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to AppKit property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView]
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"macos: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview]
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("macos: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// TODO: NSButton.target + NSButton.action pattern via FFI
node.attributes
.push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: find NSWindow or NSViewController and set root as contentView
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply AppKit mutations
Ok(())
}
}
+12
View File
@@ -0,0 +1,12 @@
//! 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;
@@ -0,0 +1,160 @@
//! 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
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Tests for el-platform backends.
#[cfg(test)]
mod tests {
use crate::{
backend_for, backends::{server::ServerBackend, web::WebBackend},
config::{PlatformConfig, PlatformTarget},
node::PlatformNode,
PlatformBackend,
};
// ── Test 1: PlatformTarget::from_str parses all targets ──────────────────
#[test]
fn test_platform_target_from_str() {
assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web));
assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server));
assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios));
assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android));
assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos));
assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux));
assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows));
assert_eq!(PlatformTarget::from_str("unknown"), None);
}
// ── Test 2: PlatformTarget::as_str round-trips ───────────────────────────
#[test]
fn test_platform_target_as_str() {
assert_eq!(PlatformTarget::Web.as_str(), "web");
assert_eq!(PlatformTarget::Server.as_str(), "server");
assert_eq!(PlatformTarget::Ios.as_str(), "ios");
}
// ── Test 3: is_native() identifies native targets ────────────────────────
#[test]
fn test_is_native() {
assert!(!PlatformTarget::Web.is_native());
assert!(!PlatformTarget::Server.is_native());
assert!(PlatformTarget::Ios.is_native());
assert!(PlatformTarget::Android.is_native());
assert!(PlatformTarget::Macos.is_native());
assert!(PlatformTarget::Linux.is_native());
assert!(PlatformTarget::Windows.is_native());
}
// ── Test 4: PlatformConfig defaults to web ───────────────────────────────
#[test]
fn test_platform_config_default() {
let cfg = PlatformConfig::default();
assert_eq!(cfg.target, PlatformTarget::Web);
assert!(!cfg.ssr);
}
// ── Test 5: PlatformConfig with_ssr ──────────────────────────────────────
#[test]
fn test_platform_config_with_ssr() {
let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true);
assert_eq!(cfg.target, PlatformTarget::Server);
assert!(cfg.ssr);
}
// ── Test 6: WebBackend renders element to HTML ───────────────────────────
#[test]
fn test_web_backend_renders_element() {
let backend = WebBackend::new();
let mut div = backend.create_element("div").unwrap();
backend.set_attribute(&mut div, "class", "container").unwrap();
backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap();
let html = backend.render_to_string(&div).unwrap();
assert!(html.contains("<div"), "should contain div tag");
assert!(html.contains("container"), "should contain class name");
assert!(html.contains("Hello"), "should contain text content");
assert!(html.contains("</div>"), "should close div tag");
}
// ── Test 7: ServerBackend supports SSR ───────────────────────────────────
#[test]
fn test_server_backend_supports_ssr() {
let backend = ServerBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 8: ServerBackend renders full HTML page ─────────────────────────
#[test]
fn test_server_backend_render_page() {
let backend = ServerBackend::new();
let root = PlatformNode::element("div")
.with_attr("id", "content")
.with_child(PlatformNode::text("Hello World"));
let page = backend.render_page(&root, "My App", "/app.js").unwrap();
assert!(page.contains("<!DOCTYPE html>"), "should be full HTML doc");
assert!(page.contains("My App"), "should include title");
assert!(page.contains("Hello World"), "should include content");
assert!(page.contains("/app.js"), "should include script src");
}
// ── Test 9: PlatformNode::to_html renders nested tree ────────────────────
#[test]
fn test_platform_node_to_html_nested() {
let node = PlatformNode::element("ul")
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1")))
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2")));
let html = node.to_html();
assert!(html.contains("<ul>"));
assert!(html.contains("<li>item 1</li>"));
assert!(html.contains("<li>item 2</li>"));
assert!(html.contains("</ul>"));
}
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
#[test]
fn test_html_escaping() {
let node = PlatformNode::element("span")
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
let html = node.to_html();
assert!(!html.contains("<script>"), "should escape <script>");
assert!(html.contains("&lt;script&gt;"), "should contain escaped form");
}
// ── Test 11: Void elements render correctly ───────────────────────────────
#[test]
fn test_void_elements() {
let node = PlatformNode::element("input").with_attr("type", "text");
let html = node.to_html();
assert!(html.contains("<input"), "should have input");
assert!(!html.contains("</input>"), "void element should not have closing tag");
assert!(html.contains("/>"), "should self-close");
}
// ── Test 12: Fragment node renders children inline ────────────────────────
#[test]
fn test_fragment_node() {
let frag = PlatformNode::fragment()
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
let html = frag.to_html();
assert!(html.contains("<span>A</span>"));
assert!(html.contains("<span>B</span>"));
// No wrapping element
assert!(!html.starts_with('<') || html.starts_with("<span>"));
}
// ── Test 13: WebBackend remove_child works ───────────────────────────────
#[test]
fn test_web_backend_remove_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("first"))
.unwrap();
backend
.append_child(&mut parent, PlatformNode::text("second"))
.unwrap();
assert_eq!(parent.children.len(), 2);
backend.remove_child(&mut parent, 0).unwrap();
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text_content(), Some("second"));
}
// ── Test 14: WebBackend replace_child works ──────────────────────────────
#[test]
fn test_web_backend_replace_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("old"))
.unwrap();
backend
.replace_child(&mut parent, 0, PlatformNode::text("new"))
.unwrap();
assert_eq!(parent.children[0].text_content(), Some("new"));
}
// ── Test 15: backend_for() returns correct backend names ─────────────────
#[test]
fn test_backend_for_names() {
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
}
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
#[test]
fn test_web_backend_bind_event() {
let backend = WebBackend::new();
let mut btn = backend.create_element("button").unwrap();
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
assert!(has_attr, "should emit data-el-click attribute");
}
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
#[test]
fn test_server_backend_bind_event() {
let backend = ServerBackend::new();
let mut btn = PlatformNode::element("button");
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
assert!(has_attr, "should emit hydration marker for click event");
}
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
#[test]
fn test_web_backend_supports_ssr() {
// Web backend supports ssr (renders to HTML for hydration)
let backend = WebBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 19: remove_child out of bounds returns error ────────────────────
#[test]
fn test_remove_child_out_of_bounds() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
let result = backend.remove_child(&mut parent, 5);
assert!(result.is_err(), "out-of-bounds remove should return error");
}
// ── Test 20: All native backends render to HTML for testing ──────────────
#[test]
fn test_native_backends_render_to_html() {
// Native backends support render_to_string for testing and SSR fallback.
let targets = [
PlatformTarget::Ios,
PlatformTarget::Android,
PlatformTarget::Macos,
PlatformTarget::Linux,
PlatformTarget::Windows,
];
for target in &targets {
let backend = backend_for(target);
let node = PlatformNode::element("div")
.with_child(PlatformNode::text("test"));
let html = backend.render_to_string(&node).unwrap();
assert!(
html.contains("test"),
"{} backend should render text content",
target.as_str()
);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
//! 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
}
}
@@ -0,0 +1,156 @@
//! Windows backend — Win32/WinUI.
//!
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a HWND or WinUI control:
//! element("div") → Panel (WinUI StackPanel)
//! element("button") → Button (WinUI)
//! element("input") → TextBox (WinUI)
//! text("...") → TextBlock (WinUI)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct WindowsBackend;
impl WindowsBackend {
pub fn new() -> Self {
Self
}
fn winui_control(tag: &str) -> &'static str {
match tag {
"button" => "Microsoft.UI.Xaml.Controls.Button",
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
"img" => "Microsoft.UI.Xaml.Controls.Image",
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
}
}
fn winui_event(event: &str) -> &'static str {
match event {
"click" => "Click",
"input" | "change" => "TextChanged",
"focus" => "GotFocus",
"blur" => "LostFocus",
"keydown" | "keypress" => "KeyDown",
"keyup" => "KeyUp",
_ => "PointerPressed",
}
}
}
impl Default for WindowsBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WindowsBackend {
fn name(&self) -> &'static str {
"windows"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: WinRT/COM activation via windows-rs crate:
// let panel: StackPanel = StackPanel::new()?;
node.attributes
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: TextBlock::new()?.set_text(content)
node.attributes
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to WinUI property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: panel.Children().Append(child_element)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"windows: child index {} out of bounds",
child_index
)));
}
// TODO: panel.Children().RemoveAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"windows: replace_child out of bounds".into(),
));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let winui_event = Self::winui_event(event);
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
node.attributes.push(crate::Attribute::new(
format!("data-winui-event-{}", event),
winui_event,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Window by container_id, set root as Content
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply WinUI mutations
Ok(())
}
}
+82
View File
@@ -0,0 +1,82 @@
//! 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
}
}
+126
View File
@@ -0,0 +1,126 @@
//! 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()),
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Platform-agnostic node tree.
//!
//! `PlatformNode` is the universal representation of a UI element.
//! Each backend converts this to its native equivalent.
/// An attribute on a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: String,
}
impl Attribute {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: value.into() }
}
}
/// A boxed event handler function.
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
/// knowing the native event type.
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
/// The kind of a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformNodeKind {
/// An element node: `<div>`, `<button>`, etc.
Element { tag: String },
/// A plain text node.
Text { content: String },
/// A component boundary marker (used for patch reconciliation).
Component { name: String },
/// A fragment — groups children without a wrapper element.
Fragment,
}
/// A platform-agnostic UI node.
///
/// This is the universal intermediate representation. Each backend renders
/// `PlatformNode` trees to its native format.
#[derive(Debug)]
pub struct PlatformNode {
pub kind: PlatformNodeKind,
pub attributes: Vec<Attribute>,
pub children: Vec<PlatformNode>,
/// Opaque platform handle — the backend stores its native pointer/reference
/// here after mounting. `None` before mount.
pub native_handle: Option<usize>,
}
impl PlatformNode {
/// Create an element node.
pub fn element(tag: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Element { tag: tag.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a text node.
pub fn text(content: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Text { content: content.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a fragment node.
pub fn fragment() -> Self {
Self {
kind: PlatformNodeKind::Fragment,
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Add an attribute.
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.push(Attribute::new(name, value));
self
}
/// Add a child node.
pub fn with_child(mut self, child: PlatformNode) -> Self {
self.children.push(child);
self
}
/// Get the tag name if this is an element node.
pub fn tag(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Element { tag } => Some(tag),
_ => None,
}
}
/// Get the text content if this is a text node.
pub fn text_content(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Text { content } => Some(content),
_ => None,
}
}
/// Render the node tree to an HTML string.
/// This is used by the server backend and for testing all backends.
pub fn to_html(&self) -> String {
match &self.kind {
PlatformNodeKind::Text { content } => html_escape(content),
PlatformNodeKind::Fragment => {
self.children.iter().map(|c| c.to_html()).collect()
}
PlatformNodeKind::Component { name } => {
format!("<!-- component:{} -->", name)
}
PlatformNodeKind::Element { tag } => {
let mut out = format!("<{}", tag);
for attr in &self.attributes {
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
}
// Void elements — no closing tag
if is_void_element(tag) {
out.push_str(" />");
return out;
}
out.push('>');
for child in &self.children {
out.push_str(&child.to_html());
}
out.push_str(&format!("</{}>", tag));
out
}
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn html_escape_attr(s: &str) -> String {
html_escape(s).replace('"', "&quot;")
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag,
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
| "link" | "meta" | "param" | "source" | "track" | "wbr"
)
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-publish"
version = "0.1.0"
edition = "2021"
description = "el-ui app publishing pipeline — one command ships to every platform"
license = "MIT"
[lib]
name = "el_publish"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+126
View File
@@ -0,0 +1,126 @@
//! 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,
}
+157
View File
@@ -0,0 +1,157 @@
//! Certificate management — load, save, check expiry, generate renewal warnings.
use crate::{PublishError, PublishResult};
use std::time::{SystemTime, UNIX_EPOCH};
/// Info about a code signing certificate.
#[derive(Debug, Clone)]
pub struct CertInfo {
pub name: String,
pub team_id: String,
pub serial: String,
/// Certificate type: "Distribution", "Development", "Push", etc.
pub cert_type: String,
/// Expiry as Unix timestamp (seconds since epoch).
pub expires_at: u64,
/// Whether the certificate is currently valid.
pub is_valid: bool,
}
impl CertInfo {
pub fn new(
name: impl Into<String>,
team_id: impl Into<String>,
serial: impl Into<String>,
cert_type: impl Into<String>,
expires_at: u64,
) -> Self {
let now = unix_now();
Self {
name: name.into(),
team_id: team_id.into(),
serial: serial.into(),
cert_type: cert_type.into(),
expires_at,
is_valid: expires_at > now,
}
}
/// Days until expiry (0 if already expired).
pub fn days_until_expiry(&self) -> u64 {
let now = unix_now();
if self.expires_at <= now {
return 0;
}
(self.expires_at - now) / 86400
}
/// Whether the cert expires within `days` days.
pub fn expires_soon(&self, days: u64) -> bool {
self.days_until_expiry() <= days
}
pub fn is_expired(&self) -> bool {
unix_now() >= self.expires_at
}
}
/// Certificate store — loads, caches, and checks expiry of code signing certs.
pub struct CertStore {
certs: Vec<CertInfo>,
/// How many days before expiry to warn (default: 30).
pub warn_days: u64,
}
impl CertStore {
pub fn new() -> Self {
Self { certs: Vec::new(), warn_days: 30 }
}
pub fn with_warn_days(mut self, days: u64) -> Self {
self.warn_days = days;
self
}
/// Add a certificate to the store.
pub fn add(&mut self, cert: CertInfo) {
self.certs.push(cert);
}
/// Find a certificate by team ID and type.
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
self.certs.iter().find(|c| {
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
})
}
/// Get all certificates expiring soon (within `warn_days`).
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
self.certs
.iter()
.filter(|c| c.expires_soon(self.warn_days))
.collect()
}
/// Generate renewal warnings for expiring certificates.
pub fn renewal_warnings(&self) -> Vec<String> {
self.expiring_soon()
.iter()
.map(|c| {
if c.is_expired() {
format!(
"EXPIRED: {} ({}) — team {}. Renew immediately.",
c.name, c.cert_type, c.team_id
)
} else {
format!(
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
c.name, c.cert_type, c.days_until_expiry(), c.team_id
)
}
})
.collect()
}
/// Check that a valid distribution certificate exists for the given team.
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
let cert = self.find(team_id, "Distribution");
match cert {
None => Err(PublishError::Certificate(format!(
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
team_id
))),
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
"Distribution certificate for team {} expires in {} days. Renew now.",
team_id,
c.days_until_expiry()
))),
_ => Ok(()),
}
}
/// Load certificates from a JSON file (stub — real impl would parse
/// Apple's certificate PEM files or keychain API).
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
Ok(Self::new())
}
/// Save certificate metadata to a JSON cache file.
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
// TODO: serialize cert metadata to JSON
Ok(())
}
}
impl Default for CertStore {
fn default() -> Self {
Self::new()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
+144
View File
@@ -0,0 +1,144 @@
//! 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
}
}
+124
View File
@@ -0,0 +1,124 @@
//! 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))
}
}
+98
View File
@@ -0,0 +1,98 @@
//! 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
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
//!
//! Directory structure (mirrors Fastlane's metadata format):
//! ```text
//! store/
//! en-US/
//! title.txt
//! description.txt
//! whats-new.txt
//! keywords.txt
//! promotional-text.txt
//! de-DE/
//! title.txt
//! ...
//! ```
use crate::{PublishError, PublishResult};
use std::collections::HashMap;
use std::path::Path;
/// Metadata for a single locale.
#[derive(Debug, Clone, Default)]
pub struct LocaleMetadata {
pub title: String,
pub description: String,
pub whats_new: String,
pub keywords: Vec<String>,
pub promotional_text: String,
}
impl LocaleMetadata {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
title: title.into(),
description: description.into(),
..Default::default()
}
}
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
self.whats_new = whats_new.into();
self
}
}
/// Complete store metadata across all locales.
#[derive(Debug, Clone, Default)]
pub struct StoreMetadata {
pub locales: HashMap<String, LocaleMetadata>,
}
impl StoreMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
self.locales.insert(locale.into(), metadata);
self
}
/// Get metadata for a specific locale, falling back to `en-US`.
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
}
/// Load metadata from the directory structure.
///
/// Falls back gracefully: if the metadata directory doesn't exist,
/// returns empty metadata (don't fail the publish for missing store copy).
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
let path = Path::new(dir);
if !path.exists() {
// Warn but don't fail — metadata is optional for internal/alpha builds.
return Ok(Self::new());
}
let mut metadata = Self::new();
let read_dir = std::fs::read_dir(path).map_err(|e| {
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
})?;
for entry in read_dir.flatten() {
let locale_path = entry.path();
if !locale_path.is_dir() {
continue;
}
let locale = locale_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if locale.is_empty() {
continue;
}
let locale_meta = LocaleMetadata {
title: read_file(&locale_path, "title.txt"),
description: read_file(&locale_path, "description.txt"),
whats_new: read_file(&locale_path, "whats-new.txt"),
keywords: read_file(&locale_path, "keywords.txt")
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect(),
promotional_text: read_file(&locale_path, "promotional-text.txt"),
};
metadata.locales.insert(locale, locale_meta);
}
Ok(metadata)
}
}
fn read_file(dir: &Path, filename: &str) -> String {
std::fs::read_to_string(dir.join(filename))
.unwrap_or_default()
.trim()
.to_string()
}
+131
View File
@@ -0,0 +1,131 @@
//! Rollout monitor — checks crash rate, advances or halts rollout.
use crate::{config::RolloutConfig, PublishError, PublishResult};
/// Current rollout state.
#[derive(Debug, Clone)]
pub struct RolloutState {
pub track: String,
pub current_percent: u8,
pub crash_rate: f64,
pub is_halted: bool,
pub hours_elapsed: u64,
}
impl RolloutState {
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
Self {
track: track.into(),
current_percent: initial_percent,
crash_rate: 0.0,
is_halted: false,
hours_elapsed: 0,
}
}
}
/// Monitors a staged rollout and decides whether to advance or halt.
pub struct RolloutMonitor {
pub config: RolloutConfig,
}
impl RolloutMonitor {
pub fn new(config: RolloutConfig) -> Self {
Self { config }
}
/// Evaluate whether to advance, halt, or maintain the current rollout.
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
if state.is_halted {
return RolloutDecision::Halted {
reason: "rollout was previously halted".into(),
};
}
// Check crash rate
if state.crash_rate > self.config.max_crash_rate {
return RolloutDecision::Halt {
reason: format!(
"crash rate {:.2}% exceeds threshold {:.2}%",
state.crash_rate * 100.0,
self.config.max_crash_rate * 100.0
),
};
}
// Check if we should advance
if self.config.auto_advance
&& state.hours_elapsed >= self.config.advance_after_hours
&& state.current_percent < 100
{
let next_percent = advance_percent(state.current_percent);
return RolloutDecision::Advance { to_percent: next_percent };
}
RolloutDecision::Maintain
}
/// Apply a rollout decision — returns the new rollout state.
pub fn apply(
&self,
mut state: RolloutState,
decision: &RolloutDecision,
) -> PublishResult<RolloutState> {
match decision {
RolloutDecision::Advance { to_percent } => {
state.current_percent = *to_percent;
state.hours_elapsed = 0;
}
RolloutDecision::Halt { reason } => {
state.is_halted = true;
let _ = reason;
}
RolloutDecision::Halted { .. } => {
return Err(PublishError::Config(
"cannot apply decision to halted rollout".into(),
));
}
RolloutDecision::Maintain => {}
}
Ok(state)
}
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
///
/// In production: fetch crash rate from Firebase Crashlytics or App Store
/// Connect Analytics API.
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
// TODO: fetch real crash rate from analytics API
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
// state.crash_rate = crash_rate;
state.hours_elapsed += 1;
let decision = self.evaluate(&state);
let new_state = self.apply(state, &decision)?;
Ok((new_state, decision))
}
}
/// The outcome of a rollout evaluation.
#[derive(Debug, Clone)]
pub enum RolloutDecision {
/// Advance to a higher rollout percentage.
Advance { to_percent: u8 },
/// Halt the rollout due to high crash rate.
Halt { reason: String },
/// Rollout was already halted.
Halted { reason: String },
/// No change needed.
Maintain,
}
/// Determine the next rollout percentage.
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
fn advance_percent(current: u8) -> u8 {
match current {
0..=9 => 10,
10..=24 => 25,
25..=49 => 50,
_ => 100,
}
}
+135
View File
@@ -0,0 +1,135 @@
//! 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,
})
}
}
+264
View File
@@ -0,0 +1,264 @@
//! 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()));
}
}
+15
View File
@@ -0,0 +1,15 @@
[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]
@@ -0,0 +1,84 @@
//! Direct binding — server-side only; calls Rust functions directly, zero network.
//!
//! Used when the component and the service implementation run in the same
//! process. Eliminates all serialization/deserialization overhead.
//!
//! The handler registry maps `"ServiceName::method_name"` to a function pointer.
//! The proxy calls the function directly.
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{ServiceError, ServiceResult};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// A direct handler function — takes params, returns a JSON body string.
pub type DirectHandler = Arc<dyn Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync>;
/// Direct binding — calls registered Rust functions without any network hop.
pub struct DirectBinding {
/// Registry: `"ServiceName::method_name"` → handler fn
handlers: Arc<RwLock<HashMap<String, DirectHandler>>>,
}
impl DirectBinding {
pub fn new() -> Self {
Self {
handlers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Register a handler for a service method.
///
/// ```
/// # use el_services::binding::direct::DirectBinding;
/// let binding = DirectBinding::new();
/// binding.register("UserService", "get_user", |params| {
/// let id = params.get("id").map(|s| s.as_str()).unwrap_or("unknown");
/// Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
/// });
/// ```
pub fn register(
&self,
service: &str,
method: &str,
handler: impl Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync + 'static,
) {
let key = format!("{}::{}", service, method);
self.handlers
.write()
.expect("lock poisoned")
.insert(key, Arc::new(handler));
}
/// Check if a handler is registered.
pub fn has_handler(&self, service: &str, method: &str) -> bool {
let key = format!("{}::{}", service, method);
self.handlers
.read()
.expect("lock poisoned")
.contains_key(&key)
}
}
impl Default for DirectBinding {
fn default() -> Self {
Self::new()
}
}
impl Binding for DirectBinding {
fn kind(&self) -> BindingKind {
BindingKind::Direct
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
let key = format!("{}::{}", request.service, request.method);
let handlers = self.handlers.read().expect("lock poisoned");
let handler = handlers.get(&key).ok_or_else(|| ServiceError::MethodNotFound {
service: request.service.clone(),
method: request.method.clone(),
})?;
let body = handler(request.params)?;
Ok(ServiceResponse::ok(body))
}
}
+97
View File
@@ -0,0 +1,97 @@
//! gRPC binding — protobuf over HTTP/2.
//!
//! The architecture is complete. The actual codegen that takes a service
//! definition and generates `.proto` files + Tonic client stubs is a TODO
//! for a future agent.
//!
//! What is implemented:
//! - `GrpcBinding` struct and `Binding` trait impl
//! - Method name → gRPC endpoint mapping (`/package.ServiceName/MethodName`)
//! - Stub call that produces the expected response format
//!
//! What needs a future agent:
//! - `.proto` file generation from service definition AST
//! - `tonic::transport::Channel` setup
//! - Actual RPC call via generated Tonic client
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
/// gRPC binding.
///
/// Uses the gRPC naming convention:
/// `/<package>.<ServiceName>/<MethodName>`
pub struct GrpcBinding {
pub config: ServiceConfig,
/// Package name prefix (e.g. "myapp.v1"). Defaults to empty.
pub package: String,
}
impl GrpcBinding {
pub fn new(config: ServiceConfig) -> Self {
Self { config, package: String::new() }
}
pub fn with_package(mut self, package: impl Into<String>) -> Self {
self.package = package.into();
self
}
/// Build the gRPC endpoint path for a method call.
/// `/package.ServiceName/MethodName` (snake_case → CamelCase for method)
pub fn grpc_endpoint(&self, method_name: &str) -> String {
let service = &self.config.name;
let method = snake_to_camel(method_name);
if self.package.is_empty() {
format!("/{}/{}", service, method)
} else {
format!("/{}.{}/{}", self.package, service, method)
}
}
}
fn snake_to_camel(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
impl Binding for GrpcBinding {
fn kind(&self) -> BindingKind {
BindingKind::Grpc
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
if self.config.base_url.is_none() {
return Err(ServiceError::Binding(
"gRPC binding requires base_url (gRPC server address) in config".into(),
));
}
let _endpoint = self.grpc_endpoint(&request.method);
// TODO (future agent): generate .proto from service AST, compile with prost,
// use tonic::transport::Channel to make the actual call:
//
// let mut client = UserServiceClient::connect(&self.config.base_url).await?;
// let request = tonic::Request::new(GetUserRequest { id: params["id"].clone() });
// let response = client.get_user(request).await?;
// return Ok(ServiceResponse::ok(serde_json::to_string(&response.into_inner())?));
Ok(ServiceResponse::ok(format!(
"{{\"grpc\":true,\"service\":\"{}\",\"method\":\"{}\"}}",
request.service, request.method
)))
}
fn supports_streaming(&self) -> bool {
// gRPC natively supports server streaming, client streaming, and bidi.
true
}
}
+114
View File
@@ -0,0 +1,114 @@
//! 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
}
}
+147
View File
@@ -0,0 +1,147 @@
//! 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
)))
}
}
@@ -0,0 +1,85 @@
//! WebSocket binding — persistent connection, message-based RPC.
//!
//! Each service method call sends a JSON message over the WebSocket and waits
//! for a response message with a matching correlation ID.
//!
//! Message format (over the wire):
//! ```json
//! { "id": "uuid", "service": "UserService", "method": "get_user", "params": { "id": "123" } }
//! ```
//!
//! Response:
//! ```json
//! { "id": "uuid", "status": 200, "body": { ... } }
//! ```
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
/// WebSocket binding — persistent connection, message-based RPC.
pub struct WebSocketBinding {
pub config: ServiceConfig,
}
impl WebSocketBinding {
pub fn new(config: ServiceConfig) -> Self {
Self { config }
}
/// Serialize a request to the wire JSON format.
pub fn serialize_request(&self, request: &ServiceRequest, correlation_id: &str) -> String {
let params_json = crate::binding::rest::serde_params_to_json(&request.params);
format!(
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\",\"params\":{}}}",
correlation_id, request.service, request.method, params_json
)
}
/// Build the WebSocket URL from the config base_url.
/// Converts `https://` → `wss://` and `http://` → `ws://`.
pub fn ws_url(&self) -> Option<String> {
self.config.base_url.as_ref().map(|url| {
url.replace("https://", "wss://")
.replace("http://", "ws://")
})
}
}
impl Binding for WebSocketBinding {
fn kind(&self) -> BindingKind {
BindingKind::WebSocket
}
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
if self.config.base_url.is_none() {
return Err(ServiceError::Binding(
"WebSocket binding requires base_url in config".into(),
));
}
// Generate a correlation ID for request/response matching
let correlation_id = simple_uuid();
let _message = self.serialize_request(&request, &correlation_id);
let _ws_url = self.ws_url();
// In production: use tungstenite or tokio-tungstenite:
// let (mut ws, _) = connect(&ws_url)?;
// ws.send(Message::Text(message))?;
// loop { let msg = ws.read_message()?; if msg_id == correlation_id { return parse(msg); } }
Ok(ServiceResponse::ok(format!(
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\"}}",
correlation_id, request.service, request.method
)))
}
fn supports_streaming(&self) -> bool {
true
}
}
fn simple_uuid() -> String {
// Deterministic for testing (not cryptographically random).
// In production: use uuid crate.
"ws-00000000-0000-0000-0000-000000000001".to_string()
}
+108
View File
@@ -0,0 +1,108 @@
//! Service configuration — reflects `[services.*]` in `el.toml`.
use crate::binding::BindingKind;
/// Authentication configuration for a service binding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthConfig {
/// No authentication.
None,
/// Bearer token (set in environment variable or injected at runtime).
Bearer { token_env: String },
/// Basic auth.
Basic { username_env: String, password_env: String },
/// API key in header.
ApiKey { header: String, key_env: String },
}
impl AuthConfig {
/// Produce the HTTP Authorization header value for this auth config.
/// Returns `None` if auth is `None` or the env var is not set.
pub fn authorization_header(&self) -> Option<String> {
match self {
Self::None => None,
Self::Bearer { token_env } => {
let token = std::env::var(token_env).ok()?;
Some(format!("Bearer {}", token))
}
Self::Basic { username_env, password_env } => {
let user = std::env::var(username_env).ok()?;
let pass = std::env::var(password_env).ok()?;
// Base64 encode user:pass
let raw = format!("{}:{}", user, pass);
let encoded = base64_encode(raw.as_bytes());
Some(format!("Basic {}", encoded))
}
Self::ApiKey { .. } => None,
}
}
/// Produce custom header key/value for API key auth.
pub fn api_key_header(&self) -> Option<(String, String)> {
match self {
Self::ApiKey { header, key_env } => {
let key = std::env::var(key_env).ok()?;
Some((header.clone(), key))
}
_ => None,
}
}
}
fn base64_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);
out.push(if chunk.len() > 1 { CHARS[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { CHARS[(n & 63) as usize] as char } else { '=' });
}
out
}
/// Full configuration for one service, from `[services.ServiceName]`.
#[derive(Debug, Clone)]
pub struct ServiceConfig {
/// Service name (matches the `service Name { ... }` declaration).
pub name: String,
/// The binding protocol.
pub binding: BindingKind,
/// Base URL (for REST/WebSocket/gRPC).
pub base_url: Option<String>,
/// Auth configuration.
pub auth: AuthConfig,
/// Connection timeout in milliseconds.
pub timeout_ms: u64,
}
impl ServiceConfig {
pub fn new(name: impl Into<String>, binding: BindingKind) -> Self {
Self {
name: name.into(),
binding,
base_url: None,
auth: AuthConfig::None,
timeout_ms: 30_000,
}
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn with_auth(mut self, auth: AuthConfig) -> Self {
self.auth = auth;
self
}
pub fn with_timeout(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
}
+51
View File
@@ -0,0 +1,51 @@
//! el-services — Service bindings for el-ui.
//!
//! Write once. Bind to any protocol. Change the binding in `el.toml`.
//!
//! ```toml
//! [services.UserService]
//! binding = "rest"
//! base_url = "https://api.example.com"
//! auth = "bearer"
//! ```
//!
//! Switch `binding = "grpc"` and the same service code now speaks gRPC. No rewrite.
pub mod binding;
pub mod config;
pub mod proxy;
pub mod registry;
pub use binding::{
direct::DirectBinding,
grpc::GrpcBinding,
rest::RestBinding,
websocket::WebSocketBinding,
Binding, BindingKind, ServiceRequest, ServiceResponse,
};
pub use config::{AuthConfig, ServiceConfig};
pub use proxy::{ServiceMethod, ServiceProxy};
pub use registry::ServiceRegistry;
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("service not found: {0}")]
NotFound(String),
#[error("binding error: {0}")]
Binding(String),
#[error("serialization error: {0}")]
Serialization(String),
#[error("transport error: {0}")]
Transport(String),
#[error("auth error: {0}")]
Auth(String),
#[error("method not found: {method} on service {service}")]
MethodNotFound { service: String, method: String },
}
pub type ServiceResult<T> = Result<T, ServiceError>;
+113
View File
@@ -0,0 +1,113 @@
//! Service proxy — the generated client for a service.
//!
//! A `ServiceProxy` wraps a binding and provides typed method calls.
//! The codegen (a future agent) will generate typed proxy structs from service
//! definitions. This is the runtime base that generated code builds on.
use crate::{
binding::{Binding, ServiceRequest, ServiceResponse},
config::ServiceConfig,
ServiceResult,
};
use std::collections::HashMap;
use std::sync::Arc;
/// A service method descriptor — used by the proxy and codegen.
#[derive(Debug, Clone)]
pub struct ServiceMethod {
pub name: String,
/// Parameter names in order (for positional call support).
pub param_names: Vec<String>,
pub return_type: String,
}
impl ServiceMethod {
pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
Self {
name: name.into(),
param_names: Vec::new(),
return_type: return_type.into(),
}
}
pub fn with_params(mut self, params: Vec<impl Into<String>>) -> Self {
self.param_names = params.into_iter().map(|p| p.into()).collect();
self
}
}
/// A runtime service proxy.
///
/// The codegen produces a typed wrapper around this.
/// Directly usable for dynamic invocation (e.g., from the AOP layer).
pub struct ServiceProxy {
pub service_name: String,
pub config: ServiceConfig,
pub binding: Arc<dyn Binding>,
pub methods: Vec<ServiceMethod>,
}
impl ServiceProxy {
pub fn new(
service_name: impl Into<String>,
config: ServiceConfig,
binding: Arc<dyn Binding>,
) -> Self {
Self {
service_name: service_name.into(),
config,
binding,
methods: Vec::new(),
}
}
pub fn with_method(mut self, method: ServiceMethod) -> Self {
self.methods.push(method);
self
}
/// Call a service method by name with named parameters.
pub fn call(
&self,
method_name: &str,
params: HashMap<String, String>,
) -> ServiceResult<ServiceResponse> {
let request = ServiceRequest {
service: self.service_name.clone(),
method: method_name.to_string(),
params,
};
self.binding.call(request)
}
/// Call a service method by name with positional parameters.
/// Parameters are matched to the method's `param_names` in order.
pub fn call_positional(
&self,
method_name: &str,
args: Vec<String>,
) -> ServiceResult<ServiceResponse> {
let method = self
.methods
.iter()
.find(|m| m.name == method_name)
.ok_or_else(|| crate::ServiceError::MethodNotFound {
service: self.service_name.clone(),
method: method_name.to_string(),
})?;
let params: HashMap<String, String> = method
.param_names
.iter()
.zip(args.iter())
.map(|(name, val)| (name.clone(), val.clone()))
.collect();
self.call(method_name, params)
}
/// List all registered method names on this proxy.
pub fn method_names(&self) -> Vec<&str> {
self.methods.iter().map(|m| m.name.as_str()).collect()
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Service registry — the global lookup table for service proxies.
use crate::{proxy::ServiceProxy, ServiceError, ServiceResult};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Global registry of all service proxies.
///
/// The framework maintains one registry per application. At startup,
/// all services are registered with their configured bindings.
/// Components call `registry.get("UserService")` to get the proxy.
#[derive(Default)]
pub struct ServiceRegistry {
services: RwLock<HashMap<String, Arc<ServiceProxy>>>,
}
impl ServiceRegistry {
pub fn new() -> Self {
Self::default()
}
/// Register a service proxy.
pub fn register(&self, proxy: ServiceProxy) {
let name = proxy.service_name.clone();
self.services
.write()
.expect("lock poisoned")
.insert(name, Arc::new(proxy));
}
/// Get a service proxy by name.
pub fn get(&self, name: &str) -> ServiceResult<Arc<ServiceProxy>> {
self.services
.read()
.expect("lock poisoned")
.get(name)
.cloned()
.ok_or_else(|| ServiceError::NotFound(name.to_string()))
}
/// List all registered service names.
pub fn service_names(&self) -> Vec<String> {
self.services
.read()
.expect("lock poisoned")
.keys()
.cloned()
.collect()
}
/// Number of registered services.
pub fn len(&self) -> usize {
self.services.read().expect("lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Tests for el-services.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use crate::{
binding::{
direct::DirectBinding,
grpc::GrpcBinding,
rest::RestBinding,
websocket::WebSocketBinding,
Binding, BindingKind, ServiceRequest, ServiceResponse,
},
config::{AuthConfig, ServiceConfig},
proxy::{ServiceMethod, ServiceProxy},
registry::ServiceRegistry,
};
fn rest_config(base_url: &str) -> ServiceConfig {
ServiceConfig::new("UserService", BindingKind::Rest)
.with_base_url(base_url)
}
// ── Test 1: BindingKind::from_str parses all kinds ───────────────────────
#[test]
fn test_binding_kind_from_str() {
assert_eq!(BindingKind::from_str("rest"), Some(BindingKind::Rest));
assert_eq!(BindingKind::from_str("websocket"), Some(BindingKind::WebSocket));
assert_eq!(BindingKind::from_str("ws"), Some(BindingKind::WebSocket));
assert_eq!(BindingKind::from_str("grpc"), Some(BindingKind::Grpc));
assert_eq!(BindingKind::from_str("direct"), Some(BindingKind::Direct));
assert_eq!(BindingKind::from_str("unknown"), None);
}
// ── Test 2: RestBinding derives correct HTTP methods ─────────────────────
#[test]
fn test_rest_http_method_derivation() {
assert_eq!(RestBinding::http_method("get_user"), "GET");
assert_eq!(RestBinding::http_method("list_users"), "GET");
assert_eq!(RestBinding::http_method("create_user"), "POST");
assert_eq!(RestBinding::http_method("update_user"), "PUT");
assert_eq!(RestBinding::http_method("delete_user"), "DELETE");
assert_eq!(RestBinding::http_method("patch_user"), "PATCH");
assert_eq!(RestBinding::http_method("process"), "POST");
}
// ── Test 3: RestBinding derives correct URL path ──────────────────────────
#[test]
fn test_rest_url_path() {
let config = rest_config("https://api.example.com");
let binding = RestBinding::new(config);
let mut params = HashMap::new();
params.insert("id".to_string(), "123".to_string());
let url = binding.url_path("get_user", &params);
assert!(url.contains("users"), "should use pluralized resource");
assert!(url.contains("123"), "should include id in URL");
}
// ── Test 4: RestBinding call returns ok response ──────────────────────────
#[test]
fn test_rest_binding_call() {
let config = rest_config("https://api.example.com");
let binding = RestBinding::new(config);
let request = ServiceRequest::new("UserService", "get_user")
.with_param("id", "42");
let response = binding.call(request).unwrap();
assert!(response.is_success());
assert!(response.body.contains("UserService"));
}
// ── Test 5: RestBinding without base_url returns error ───────────────────
#[test]
fn test_rest_binding_no_base_url_errors() {
let config = ServiceConfig::new("UserService", BindingKind::Rest);
let binding = RestBinding::new(config);
let request = ServiceRequest::new("UserService", "get_user");
let result = binding.call(request);
assert!(result.is_err(), "should error without base_url");
}
// ── Test 6: DirectBinding registers and calls handlers ───────────────────
#[test]
fn test_direct_binding_register_and_call() {
let binding = DirectBinding::new();
binding.register("UserService", "get_user", |params| {
let id = params.get("id").cloned().unwrap_or_default();
Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
});
let request = ServiceRequest::new("UserService", "get_user")
.with_param("id", "42");
let response = binding.call(request).unwrap();
assert!(response.is_success());
assert!(response.body.contains("Alice"));
assert!(response.body.contains("42"));
}
// ── Test 7: DirectBinding returns error for missing handler ──────────────
#[test]
fn test_direct_binding_missing_handler() {
let binding = DirectBinding::new();
let request = ServiceRequest::new("UserService", "nonexistent");
let result = binding.call(request);
assert!(result.is_err(), "should error on missing handler");
}
// ── Test 8: DirectBinding::has_handler works ─────────────────────────────
#[test]
fn test_direct_binding_has_handler() {
let binding = DirectBinding::new();
assert!(!binding.has_handler("UserService", "get_user"));
binding.register("UserService", "get_user", |_| Ok("{}".into()));
assert!(binding.has_handler("UserService", "get_user"));
}
// ── Test 9: GrpcBinding builds correct endpoint path ────────────────────
#[test]
fn test_grpc_endpoint_path() {
let config = ServiceConfig::new("UserService", BindingKind::Grpc)
.with_base_url("http://localhost:50051");
let binding = GrpcBinding::new(config).with_package("myapp.v1");
let endpoint = binding.grpc_endpoint("get_user");
assert_eq!(endpoint, "/myapp.v1.UserService/GetUser");
}
// ── Test 10: GrpcBinding without package ────────────────────────────────
#[test]
fn test_grpc_endpoint_no_package() {
let config = ServiceConfig::new("OrderService", BindingKind::Grpc)
.with_base_url("http://localhost:50051");
let binding = GrpcBinding::new(config);
let endpoint = binding.grpc_endpoint("list_orders");
assert_eq!(endpoint, "/OrderService/ListOrders");
}
// ── Test 11: WebSocketBinding converts URL protocol ──────────────────────
#[test]
fn test_websocket_url_conversion() {
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
.with_base_url("https://ws.example.com");
let binding = WebSocketBinding::new(config);
let ws_url = binding.ws_url().unwrap();
assert!(ws_url.starts_with("wss://"), "should convert https to wss");
}
// ── Test 12: WebSocketBinding serializes request correctly ───────────────
#[test]
fn test_websocket_request_serialization() {
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
.with_base_url("wss://ws.example.com");
let binding = WebSocketBinding::new(config);
let request = ServiceRequest::new("ChatService", "send_message")
.with_param("content", "hello");
let msg = binding.serialize_request(&request, "test-id-123");
assert!(msg.contains("test-id-123"));
assert!(msg.contains("ChatService"));
assert!(msg.contains("send_message"));
assert!(msg.contains("hello"));
}
// ── Test 13: ServiceProxy::call_positional maps args to params ───────────
#[test]
fn test_service_proxy_positional_call() {
let binding = DirectBinding::new();
binding.register("UserService", "create_user", |params| {
let name = params.get("name").cloned().unwrap_or_default();
let email = params.get("email").cloned().unwrap_or_default();
Ok(format!("{{\"name\":\"{}\",\"email\":\"{}\"}}", name, email))
});
let config = ServiceConfig::new("UserService", BindingKind::Direct);
let method = ServiceMethod::new("create_user", "User")
.with_params(vec!["name", "email"]);
let proxy = ServiceProxy::new("UserService", config, Arc::new(binding))
.with_method(method);
let response = proxy
.call_positional("create_user", vec!["Alice".into(), "alice@example.com".into()])
.unwrap();
assert!(response.body.contains("Alice"));
assert!(response.body.contains("alice@example.com"));
}
// ── Test 14: ServiceRegistry stores and retrieves services ───────────────
#[test]
fn test_service_registry() {
let registry = ServiceRegistry::new();
let config = ServiceConfig::new("UserService", BindingKind::Direct);
let proxy = ServiceProxy::new("UserService", config, Arc::new(DirectBinding::new()));
registry.register(proxy);
let retrieved = registry.get("UserService").unwrap();
assert_eq!(retrieved.service_name, "UserService");
}
// ── Test 15: ServiceRegistry returns error for unknown service ────────────
#[test]
fn test_service_registry_not_found() {
let registry = ServiceRegistry::new();
let result = registry.get("NonExistentService");
assert!(result.is_err());
}
// ── Test 16: ServiceConfig with_auth sets bearer token ───────────────────
#[test]
fn test_service_config_with_auth() {
let config = ServiceConfig::new("UserService", BindingKind::Rest)
.with_base_url("https://api.example.com")
.with_auth(AuthConfig::Bearer { token_env: "API_TOKEN".into() });
match &config.auth {
AuthConfig::Bearer { token_env } => assert_eq!(token_env, "API_TOKEN"),
_ => panic!("expected Bearer auth"),
}
}
// ── Test 17: ServiceResponse::is_success ─────────────────────────────────
#[test]
fn test_service_response_success() {
assert!(ServiceResponse::ok("{}").is_success());
assert!(!ServiceResponse::error(404, "not found").is_success());
assert!(!ServiceResponse::error(500, "error").is_success());
}
// ── Test 18: WebSocketBinding supports streaming ─────────────────────────
#[test]
fn test_websocket_supports_streaming() {
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
.with_base_url("wss://ws.example.com");
let binding = WebSocketBinding::new(config);
assert!(binding.supports_streaming());
}
// ── Test 19: RestBinding::build_request_description includes method ───────
#[test]
fn test_rest_request_description() {
let config = rest_config("https://api.example.com");
let binding = RestBinding::new(config);
let request = ServiceRequest::new("UserService", "create_user")
.with_param("name", "Alice");
let desc = binding.build_request_description(&request);
assert!(desc.contains("POST"), "create_ maps to POST");
assert!(desc.contains("users"), "resource name derived from service");
}
// ── Test 20: ServiceRegistry lists all service names ─────────────────────
#[test]
fn test_service_registry_list() {
let registry = ServiceRegistry::new();
for name in ["UserService", "OrderService", "ProductService"] {
let config = ServiceConfig::new(name, BindingKind::Direct);
let proxy = ServiceProxy::new(name, config, Arc::new(DirectBinding::new()));
registry.register(proxy);
}
let mut names = registry.service_names();
names.sort();
assert_eq!(names, vec!["OrderService", "ProductService", "UserService"]);
assert_eq!(registry.len(), 3);
}
}
+179 -1
View File
@@ -5,20 +5,95 @@
//! 2. Stores each `state` field as a node in an in-instance `Graph`.
//! 3. Implements `render()` returning an HTML string.
//! 4. Uses `setState()` to trigger spreading activation and DOM patching.
//!
//! ## Codegen targets
//!
//! The `CodegenTarget` enum selects the output format:
//!
//! - `Web` — ES2022 module (current, default behavior)
//! - `Server` — Rust code calling the `el-platform` server backend for SSR
//! - `Native(Platform)` — Rust code calling the `el-platform` native backend trait
use crate::ast::*;
use crate::error::CompileResult;
/// Which native platform to target when using `CodegenTarget::Native`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Platform {
Ios,
Android,
Macos,
Linux,
Windows,
}
impl Platform {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ios => "ios",
Self::Android => "android",
Self::Macos => "macos",
Self::Linux => "linux",
Self::Windows => "windows",
}
}
}
/// Selects the output format for the code generator.
///
/// - `Web` — ES2022 JavaScript module (default, uses the el-ui JS runtime)
/// - `Server` — Rust module that uses `el_platform::ServerBackend` for SSR.
/// The generated Rust struct implements a `render_to_html()` method.
/// - `Native(Platform)` — Rust module calling `el_platform::PlatformBackend`
/// for the specified native platform (iOS, Android, macOS, Linux, Windows).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodegenTarget {
/// Current default — ES2022 JavaScript module for the browser.
Web,
/// Server-side rendering — generates Rust code using `el_platform::ServerBackend`.
Server,
/// Native platform — generates Rust code using the platform backend trait.
Native(Platform),
}
impl CodegenTarget {
pub fn is_web(&self) -> bool {
matches!(self, Self::Web)
}
pub fn is_rust_output(&self) -> bool {
matches!(self, Self::Server | Self::Native(_))
}
}
pub struct Codegen {
runtime_path: String,
/// The compilation target. Defaults to `Web`.
pub target: CodegenTarget,
}
impl Codegen {
pub fn new(runtime_path: &str) -> Self {
Self { runtime_path: runtime_path.to_owned() }
Self {
runtime_path: runtime_path.to_owned(),
target: CodegenTarget::Web,
}
}
pub fn with_target(mut self, target: CodegenTarget) -> Self {
self.target = target;
self
}
pub fn generate(&self, components: &[Component]) -> CompileResult<String> {
match &self.target {
CodegenTarget::Web => self.generate_web(components),
CodegenTarget::Server => self.generate_server_rust(components),
CodegenTarget::Native(platform) => self.generate_native_rust(components, platform),
}
}
fn generate_web(&self, components: &[Component]) -> CompileResult<String> {
let mut out = String::new();
// Runtime import
@@ -41,6 +116,109 @@ impl Codegen {
Ok(out)
}
/// Generate Rust code for the `server` target.
///
/// The output is a Rust module where each component is a struct implementing
/// `render_to_html(&self) -> String` using `el_platform::ServerBackend`.
fn generate_server_rust(&self, components: &[Component]) -> CompileResult<String> {
let mut out = String::new();
out.push_str("//! el-ui SSR — generated by el-ui-compiler (target: server)\n");
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target server\n\n");
out.push_str("use el_platform::{ServerBackend, PlatformBackend, PlatformNode};\n\n");
for comp in components {
out.push_str(&format!("/// Server-side rendered component: {}\n", comp.name));
out.push_str(&format!("pub struct {} {{\n", comp.name));
for prop in &comp.props {
out.push_str(&format!(" pub {}: String,\n", prop.name));
}
for state in &comp.state {
out.push_str(&format!(" pub {}: String,\n", state.name));
}
out.push_str("}\n\n");
out.push_str(&format!("impl {} {{\n", comp.name));
out.push_str(" pub fn render_to_html(&self) -> String {\n");
out.push_str(" let backend = ServerBackend::new();\n");
// Generate a simple element tree based on the template
out.push_str(" let root = self.build_node_tree();\n");
out.push_str(" backend.render_to_string(&root).unwrap_or_default()\n");
out.push_str(" }\n\n");
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
out.push_str(" // The compiler translates each TemplateNode into\n");
out.push_str(" // PlatformNode::element() / PlatformNode::text() calls.\n");
out.push_str(" PlatformNode::element(\"div\")\n");
out.push_str(" }\n");
out.push_str("}\n\n");
}
Ok(out)
}
/// Generate Rust code for a `native` target.
///
/// The output is a Rust module where each component builds a `PlatformNode`
/// tree and calls the appropriate backend to mount it.
fn generate_native_rust(&self, components: &[Component], platform: &Platform) -> CompileResult<String> {
let backend_type = match platform {
Platform::Ios => "IosBackend",
Platform::Android => "AndroidBackend",
Platform::Macos => "MacosBackend",
Platform::Linux => "LinuxBackend",
Platform::Windows => "WindowsBackend",
};
let mut out = String::new();
out.push_str(&format!(
"//! el-ui native — generated by el-ui-compiler (target: {})\n",
platform.as_str()
));
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target <platform>\n\n");
out.push_str(&format!(
"use el_platform::{{{} as Backend, PlatformBackend, PlatformNode}};\n\n",
backend_type
));
for comp in components {
out.push_str(&format!("/// Native component: {} ({})\n", comp.name, platform.as_str()));
out.push_str(&format!("pub struct {} {{\n", comp.name));
for prop in &comp.props {
out.push_str(&format!(" pub {}: String,\n", prop.name));
}
for state in &comp.state {
out.push_str(&format!(" pub {}: String,\n", state.name));
}
out.push_str(" backend: Backend,\n");
out.push_str("}\n\n");
out.push_str(&format!("impl {} {{\n", comp.name));
out.push_str(" pub fn new() -> Self {\n");
out.push_str(" Self {\n");
for prop in &comp.props {
let default = prop.default.as_deref().unwrap_or("\"\"");
out.push_str(&format!(" {}: {}.to_string(),\n", prop.name, default));
}
for state in &comp.state {
out.push_str(&format!(" {}: {}.to_string(),\n", state.name, state.initial));
}
out.push_str(" backend: Backend::new(),\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
out.push_str(" pub fn mount(&self, container_id: &str) -> el_platform::PlatformResult<()> {\n");
out.push_str(" let root = self.build_node_tree();\n");
out.push_str(" self.backend.mount(root, container_id)\n");
out.push_str(" }\n\n");
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
out.push_str(" PlatformNode::element(\"div\")\n");
out.push_str(" }\n");
out.push_str("}\n\n");
}
Ok(out)
}
fn gen_component(&self, comp: &Component) -> CompileResult<String> {
let mut out = String::new();