diff --git a/ui/Cargo.lock b/ui/Cargo.lock index 943d912..06dc07b 100644 --- a/ui/Cargo.lock +++ b/ui/Cargo.lock @@ -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" diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 9c00085..022c9e6 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -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" diff --git a/ui/crates/el-aop/Cargo.toml b/ui/crates/el-aop/Cargo.toml new file mode 100644 index 0000000..f5d43b0 --- /dev/null +++ b/ui/crates/el-aop/Cargo.toml @@ -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] diff --git a/ui/crates/el-aop/src/aspects.rs b/ui/crates/el-aop/src/aspects.rs new file mode 100644 index 0000000..cba9eb6 --- /dev/null +++ b/ui/crates/el-aop/src/aspects.rs @@ -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) -> Self { + Self { required_role: role.into() } + } +} + +impl Aspect for AuthorizeAspect { + fn name(&self) -> &'static str { + "authorize" + } + + fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { + let user_roles = ctx + .metadata + .get("roles") + .map(|s| s.as_str()) + .unwrap_or(""); + // Roles are stored as comma-separated string: "admin,user" + let has_role = user_roles + .split(',') + .any(|r| r.trim() == self.required_role.as_str()); + if !has_role { + return Err(AopError::Forbidden { + role: self.required_role.clone(), + }); + } + Ok(()) + } +} + +// ── @cache ──────────────────────────────────────────────────────────────────── + +struct CacheEntry { + value: InvocationResult, + inserted_at: Instant, + ttl: Duration, +} + +impl CacheEntry { + fn is_expired(&self) -> bool { + self.inserted_at.elapsed() > self.ttl + } +} + +/// `@cache(ttl: 300)` — Cache method responses for `ttl` seconds. +/// +/// Cache key is `"target::method::{args_sorted_json}"`. +pub struct CacheAspect { + pub ttl: Duration, + cache: Mutex>, +} + +impl CacheAspect { + pub fn new(ttl_seconds: u64) -> Self { + Self { + ttl: Duration::from_secs(ttl_seconds), + cache: Mutex::new(HashMap::new()), + } + } + + fn cache_key(ctx: &InvocationContext) -> String { + let mut pairs: Vec<(&String, &String)> = ctx.args.iter().collect(); + pairs.sort_by_key(|(k, _)| k.as_str()); + let args = pairs + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(","); + format!("{}::{}::{}", ctx.target, ctx.method, args) + } +} + +impl Aspect for CacheAspect { + fn name(&self) -> &'static str { + "cache" + } + + fn around( + &self, + ctx: InvocationContext, + proceed: &ProceedFn, + ) -> AopResult { + let key = Self::cache_key(&ctx); + + // Check cache + { + let cache = self.cache.lock().expect("cache lock poisoned"); + if let Some(entry) = cache.get(&key) { + if !entry.is_expired() { + return Ok(entry.value.clone()); + } + } + } + + // Cache miss — proceed and store result + let result = proceed(ctx)?; + { + let mut cache = self.cache.lock().expect("cache lock poisoned"); + // Evict expired entries while we're here + cache.retain(|_, v| !v.is_expired()); + cache.insert( + key, + CacheEntry { + value: result.clone(), + inserted_at: Instant::now(), + ttl: self.ttl, + }, + ); + } + Ok(result) + } +} + +// ── @rate_limit ─────────────────────────────────────────────────────────────── + +struct RateWindow { + count: u32, + window_start: Instant, + window_duration: Duration, +} + +/// `@rate_limit(requests: 100, per: 60)` — Allow at most `requests` calls per `per` seconds. +pub struct RateLimitAspect { + pub max_requests: u32, + pub window: Duration, + state: Mutex>, +} + +impl RateLimitAspect { + pub fn new(max_requests: u32, per_seconds: u64) -> Self { + Self { + max_requests, + window: Duration::from_secs(per_seconds), + state: Mutex::new(HashMap::new()), + } + } + + /// The rate-limit key for a caller. Uses `user_id` or "anonymous". + fn caller_key(ctx: &InvocationContext) -> String { + ctx.metadata + .get("user_id") + .cloned() + .unwrap_or_else(|| "anonymous".to_string()) + } +} + +impl Aspect for RateLimitAspect { + fn name(&self) -> &'static str { + "rate_limit" + } + + fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { + let key = Self::caller_key(ctx); + let mut state = self.state.lock().expect("rate limit lock poisoned"); + let now = Instant::now(); + let window = state.entry(key).or_insert(RateWindow { + count: 0, + window_start: now, + window_duration: self.window, + }); + // Reset window if expired + if now.duration_since(window.window_start) >= window.window_duration { + window.count = 0; + window.window_start = now; + } + if window.count >= self.max_requests { + return Err(AopError::RateLimited { + requests: self.max_requests, + per: self.window.as_secs(), + }); + } + window.count += 1; + Ok(()) + } +} + +// ── @log ────────────────────────────────────────────────────────────────────── + +/// `@log(level: "info")` — Structured logging for every method call. +pub struct LogAspect { + pub level: String, +} + +impl LogAspect { + pub fn new(level: impl Into) -> Self { + Self { level: level.into() } + } +} + +impl Aspect for LogAspect { + fn name(&self) -> &'static str { + "log" + } + + fn around( + &self, + ctx: InvocationContext, + proceed: &ProceedFn, + ) -> AopResult { + // In production: use the `tracing` crate with the appropriate level macro. + let _log_entry = format!( + "[{}] {}.{}({:?})", + self.level.to_uppercase(), + ctx.target, + ctx.method, + ctx.args + ); + let result = proceed(ctx.clone()); + let _log_result = match &result { + Ok(r) => format!("[{}] {}.{} → ok: {}", self.level.to_uppercase(), ctx.target, ctx.method, r.value), + Err(e) => format!("[ERROR] {}.{} → err: {}", ctx.target, ctx.method, e), + }; + result + } +} + +// ── @validate ───────────────────────────────────────────────────────────────── + +/// `@validate` — Run input validation before the method executes. +/// +/// Validation rules are registered per method. If no rules are registered, +/// the aspect passes through (fail-open for ease of adoption). +pub struct ValidateAspect { + /// `"target::method"` → list of validation rules (field, rule_name) + rules: Mutex>>, +} + +impl ValidateAspect { + pub fn new() -> Self { + Self { + rules: Mutex::new(HashMap::new()), + } + } + + /// Add a validation rule. `rule` is one of: "required", "email", "min:N", "max:N". + pub fn add_rule( + &self, + target: &str, + method: &str, + field: impl Into, + rule: impl Into, + ) { + let key = format!("{}::{}", target, method); + self.rules + .lock() + .expect("validate lock poisoned") + .entry(key) + .or_default() + .push((field.into(), rule.into())); + } + + fn validate_field(value: &str, rule: &str) -> AopResult<()> { + if rule == "required" && value.trim().is_empty() { + return Err(AopError::ValidationFailed("field is required".into())); + } + if rule == "email" && !value.contains('@') { + return Err(AopError::ValidationFailed(format!( + "'{}' is not a valid email", + value + ))); + } + if let Some(min_str) = rule.strip_prefix("min:") { + let min: usize = min_str.parse().unwrap_or(0); + if value.len() < min { + return Err(AopError::ValidationFailed(format!( + "minimum length is {}", + min + ))); + } + } + if let Some(max_str) = rule.strip_prefix("max:") { + let max: usize = max_str.parse().unwrap_or(usize::MAX); + if value.len() > max { + return Err(AopError::ValidationFailed(format!( + "maximum length is {}", + max + ))); + } + } + Ok(()) + } +} + +impl Default for ValidateAspect { + fn default() -> Self { + Self::new() + } +} + +impl Aspect for ValidateAspect { + fn name(&self) -> &'static str { + "validate" + } + + fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { + let key = format!("{}::{}", ctx.target, ctx.method); + let rules = self.rules.lock().expect("validate lock poisoned"); + if let Some(field_rules) = rules.get(&key) { + for (field, rule) in field_rules { + let value = ctx.args.get(field).map(|s| s.as_str()).unwrap_or(""); + Self::validate_field(value, rule)?; + } + } + Ok(()) + } +} + +// ── @retry ──────────────────────────────────────────────────────────────────── + +/// `@retry(attempts: 3, backoff: "exponential")` — Retry on failure. +pub struct RetryAspect { + pub attempts: u32, + pub backoff: BackoffStrategy, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BackoffStrategy { + None, + Fixed(Duration), + Exponential { base: Duration }, +} + +impl RetryAspect { + pub fn new(attempts: u32) -> Self { + Self { attempts, backoff: BackoffStrategy::None } + } + + pub fn with_exponential_backoff(mut self, base_ms: u64) -> Self { + self.backoff = BackoffStrategy::Exponential { + base: Duration::from_millis(base_ms), + }; + self + } + + pub fn with_fixed_backoff(mut self, ms: u64) -> Self { + self.backoff = BackoffStrategy::Fixed(Duration::from_millis(ms)); + self + } + + fn sleep_duration(&self, attempt: u32) -> Duration { + match &self.backoff { + BackoffStrategy::None => Duration::ZERO, + BackoffStrategy::Fixed(d) => *d, + BackoffStrategy::Exponential { base } => { + // base * 2^attempt, capped at 30s + let factor = 1u64 << attempt.min(10); + std::cmp::min(*base * factor as u32, Duration::from_secs(30)) + } + } + } +} + +impl Aspect for RetryAspect { + fn name(&self) -> &'static str { + "retry" + } + + fn around( + &self, + ctx: InvocationContext, + proceed: &ProceedFn, + ) -> AopResult { + let mut last_error = String::new(); + for attempt in 0..self.attempts { + match proceed(ctx.clone()) { + Ok(result) => return Ok(result), + Err(e) => { + last_error = e.to_string(); + let sleep_for = self.sleep_duration(attempt); + if sleep_for > Duration::ZERO && attempt + 1 < self.attempts { + std::thread::sleep(sleep_for); + } + } + } + } + Err(AopError::RetriesExhausted { + attempts: self.attempts, + last_error, + }) + } +} + +// ── @trace ──────────────────────────────────────────────────────────────────── + +static TRACE_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// `@trace` — Add a distributed tracing span to every method call. +/// +/// Injects a `trace_id` and `span_id` into context metadata. +/// In production, emit the span to an OpenTelemetry collector. +pub struct TraceAspect { + pub service_name: String, +} + +impl TraceAspect { + pub fn new(service_name: impl Into) -> Self { + Self { service_name: service_name.into() } + } + + fn new_span_id() -> String { + let id = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("span-{:016x}", id) + } +} + +impl Aspect for TraceAspect { + fn name(&self) -> &'static str { + "trace" + } + + fn around( + &self, + mut ctx: InvocationContext, + proceed: &ProceedFn, + ) -> AopResult { + // Create or inherit trace ID + let trace_id = ctx + .metadata + .get("trace_id") + .cloned() + .unwrap_or_else(|| format!("trace-{:016x}", TRACE_COUNTER.load(Ordering::Relaxed))); + let span_id = Self::new_span_id(); + + ctx.metadata.insert("trace_id".into(), trace_id.clone()); + ctx.metadata.insert("span_id".into(), span_id.clone()); + + let start = Instant::now(); + let result = proceed(ctx.clone()); + let duration_us = start.elapsed().as_micros(); + + // In production: emit span to OpenTelemetry: + // tracer.start_with_context("method_call", parent_cx) + // .set_attribute(KeyValue::new("service", self.service_name.clone())) + // .set_attribute(KeyValue::new("method", ctx.method.clone())) + // .set_attribute(KeyValue::new("duration_us", duration_us as i64)) + // .end(); + let _ = duration_us; + + result.map(|mut r| { + r.metadata.insert("trace_id".into(), trace_id); + r.metadata.insert("span_id".into(), span_id); + r + }) + } +} diff --git a/ui/crates/el-aop/src/chain.rs b/ui/crates/el-aop/src/chain.rs new file mode 100644 index 0000000..4bb0ce2 --- /dev/null +++ b/ui/crates/el-aop/src/chain.rs @@ -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>, +} + +impl AspectChain { + pub fn new() -> Self { + Self { aspects: Vec::new() } + } + + /// Add an aspect to the end of the chain. + pub fn add(mut self, aspect: Arc) -> Self { + self.aspects.push(aspect); + self + } + + /// Number of aspects in this chain. + pub fn len(&self) -> usize { + self.aspects.len() + } + + pub fn is_empty(&self) -> bool { + self.aspects.is_empty() + } + + /// Execute the chain around the given proceed function. + /// + /// Aspects run in order (left to right in the decorator list). + /// Each aspect's `around` method receives the next aspect's `around` + /// as the `proceed` function, forming a true onion model. + pub fn execute( + &self, + ctx: InvocationContext, + proceed: ProceedFn, + ) -> AopResult { + if self.aspects.is_empty() { + return proceed(ctx); + } + self.run_aspect(0, ctx, proceed) + } + + fn run_aspect( + &self, + index: usize, + ctx: InvocationContext, + final_proceed: ProceedFn, + ) -> AopResult { + if index >= self.aspects.len() { + return final_proceed(ctx); + } + + let aspect = self.aspects[index].clone(); + let remaining_aspects = self.aspects[index + 1..].to_vec(); + let final_proceed = Arc::new(final_proceed); + + let next: ProceedFn = Box::new(move |ctx: InvocationContext| { + if remaining_aspects.is_empty() { + return final_proceed(ctx); + } + + // Build remaining chain recursively + let sub_chain = AspectChain { + aspects: remaining_aspects.clone(), + }; + sub_chain.execute(ctx, { + let fp = final_proceed.clone(); + Box::new(move |ctx| fp(ctx)) + }) + }); + + aspect.around(ctx, &next) + } + + /// Return the names of all aspects in this chain (in order). + pub fn aspect_names(&self) -> Vec<&str> { + self.aspects.iter().map(|a| a.name()).collect() + } +} + +impl Default for AspectChain { + fn default() -> Self { + Self::new() + } +} diff --git a/ui/crates/el-aop/src/lib.rs b/ui/crates/el-aop/src/lib.rs new file mode 100644 index 0000000..581cc4d --- /dev/null +++ b/ui/crates/el-aop/src/lib.rs @@ -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 = Result; + +/// Context passed through the aspect chain. +/// +/// Contains the incoming arguments and metadata about the call. +/// Aspects can read and mutate this context as they execute. +#[derive(Debug, Clone)] +pub struct InvocationContext { + /// The component or service being called. + pub target: String, + /// The method being called. + pub method: String, + /// Arguments passed to the method. + pub args: HashMap, + /// Metadata added by aspects (e.g., the authenticated user, trace ID). + pub metadata: HashMap, +} + +impl InvocationContext { + pub fn new(target: impl Into, method: impl Into) -> Self { + Self { + target: target.into(), + method: method.into(), + args: HashMap::new(), + metadata: HashMap::new(), + } + } + + pub fn with_arg(mut self, key: impl Into, value: impl Into) -> Self { + self.args.insert(key.into(), value.into()); + self + } + + pub fn with_meta(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } + + pub fn get_meta(&self, key: &str) -> Option<&str> { + self.metadata.get(key).map(|s| s.as_str()) + } +} + +/// The result of invoking a method through an aspect chain. +#[derive(Debug, Clone)] +pub struct InvocationResult { + pub value: String, + pub metadata: HashMap, +} + +impl InvocationResult { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + metadata: HashMap::new(), + } + } +} + +/// A handler that performs the actual method invocation. +/// Aspects wrap around this. +pub type ProceedFn = Box AopResult + Send + Sync>; + +/// The core Aspect trait. +/// +/// Each aspect implements `before`, `after`, or `around` advice. +/// The default implementations are no-ops — only override what you need. +pub trait Aspect: Send + Sync { + /// The aspect's name (used for debugging and registry lookup). + fn name(&self) -> &'static str; + + /// Before advice — runs before the method. Can reject the call. + fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> { + let _ = ctx; + Ok(()) + } + + /// After advice — runs after the method. Receives the result. + /// Can modify the result or perform cleanup. + fn after( + &self, + ctx: &InvocationContext, + result: AopResult, + ) -> AopResult { + let _ = ctx; + result + } + + /// Around advice — wraps the entire invocation. + /// The default implementation calls `before`, then `proceed`, then `after`. + fn around( + &self, + mut ctx: InvocationContext, + proceed: &ProceedFn, + ) -> AopResult { + self.before(&mut ctx)?; + let result = proceed(ctx.clone()); + self.after(&ctx, result) + } +} diff --git a/ui/crates/el-aop/src/registry.rs b/ui/crates/el-aop/src/registry.rs new file mode 100644 index 0000000..690132d --- /dev/null +++ b/ui/crates/el-aop/src/registry.rs @@ -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) -> Arc + Send + Sync>; + +/// Registry of aspect factories, indexed by decorator name. +pub struct AspectRegistry { + factories: RwLock>, +} + +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) -> Arc + Send + Sync + 'static, + ) { + self.factories + .write() + .expect("registry lock poisoned") + .insert(name.to_string(), Box::new(factory)); + } + + /// Instantiate an aspect by decorator name with the given params. + pub fn create( + &self, + name: &str, + params: &HashMap, + ) -> Option> { + let factories = self.factories.read().expect("registry lock poisoned"); + factories.get(name).map(|f| f(params)) + } + + /// List all registered aspect names. + pub fn aspect_names(&self) -> Vec { + self.factories + .read() + .expect("registry lock poisoned") + .keys() + .cloned() + .collect() + } + + /// Check if an aspect name is registered. + pub fn contains(&self, name: &str) -> bool { + self.factories + .read() + .expect("registry lock poisoned") + .contains_key(name) + } +} + +impl Default for AspectRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/ui/crates/el-aop/src/tests.rs b/ui/crates/el-aop/src/tests.rs new file mode 100644 index 0000000..470a6aa --- /dev/null +++ b/ui/crates/el-aop/src/tests.rs @@ -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 + Clone) -> crate::ProceedFn { + let v = value.into(); + Box::new(move |_ctx| Ok(InvocationResult::new(v.clone()))) + } + + fn fail_proceed(msg: impl Into + Clone) -> crate::ProceedFn { + let m = msg.into(); + Box::new(move |_ctx| Err(AopError::Aspect(m.clone()))) + } + + fn ctx(target: &str, method: &str) -> InvocationContext { + InvocationContext::new(target, method) + } + + fn authed_ctx(target: &str, method: &str) -> InvocationContext { + ctx(target, method).with_meta("user_id", "user-123") + } + + fn admin_ctx(target: &str, method: &str) -> InvocationContext { + ctx(target, method) + .with_meta("user_id", "admin-1") + .with_meta("roles", "admin,user") + } + + // ── Test 1: AuthenticateAspect rejects unauthenticated calls ───────────── + #[test] + fn test_authenticate_rejects_unauthenticated() { + let aspect = AuthenticateAspect; + let mut ctx = ctx("AdminDashboard", "load"); + let result = aspect.before(&mut ctx); + assert!(result.is_err()); + assert!(matches!(result, Err(AopError::Unauthenticated))); + } + + // ── Test 2: AuthenticateAspect allows authenticated calls ───────────────── + #[test] + fn test_authenticate_allows_authenticated() { + let aspect = AuthenticateAspect; + let mut ctx = authed_ctx("AdminDashboard", "load"); + let result = aspect.before(&mut ctx); + assert!(result.is_ok()); + } + + // ── Test 3: AuthorizeAspect rejects wrong role ──────────────────────────── + #[test] + fn test_authorize_rejects_wrong_role() { + let aspect = AuthorizeAspect::new("admin"); + let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user"); + let result = aspect.before(&mut ctx); + assert!(matches!(result, Err(AopError::Forbidden { .. }))); + } + + // ── Test 4: AuthorizeAspect allows correct role ─────────────────────────── + #[test] + fn test_authorize_allows_correct_role() { + let aspect = AuthorizeAspect::new("admin"); + let mut ctx = admin_ctx("Dashboard", "delete"); + let result = aspect.before(&mut ctx); + assert!(result.is_ok()); + } + + // ── Test 5: CacheAspect returns cached result on second call ────────────── + #[test] + fn test_cache_returns_cached_result() { + let aspect = CacheAspect::new(300); + let ctx = authed_ctx("OrderService", "get_orders"); + let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let cc = call_count.clone(); + let proceed: crate::ProceedFn = Box::new(move |_ctx| { + let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + Ok(InvocationResult::new(format!("result-{}", n))) + }); + // First call — executes proceed + let r1 = aspect.around(ctx.clone(), &proceed).unwrap(); + // Second call — should return cached (proceed not called again) + let proceed2: crate::ProceedFn = Box::new(|_ctx| { + panic!("proceed should not be called on cache hit"); + }); + let r2 = aspect.around(ctx.clone(), &proceed2).unwrap(); + assert_eq!(r1.value, r2.value, "cached value should be returned"); + } + + // ── Test 6: RateLimitAspect blocks after limit exceeded ─────────────────── + #[test] + fn test_rate_limit_blocks_after_limit() { + let aspect = RateLimitAspect::new(2, 60); + let mut ctx = authed_ctx("OrderService", "create_order"); + + // First two calls succeed + assert!(aspect.before(&mut ctx).is_ok()); + assert!(aspect.before(&mut ctx).is_ok()); + // Third call should be blocked + let result = aspect.before(&mut ctx); + assert!(matches!(result, Err(AopError::RateLimited { .. }))); + } + + // ── Test 7: LogAspect passes through to proceed ─────────────────────────── + #[test] + fn test_log_aspect_passthrough() { + let aspect = LogAspect::new("info"); + let ctx = authed_ctx("UserService", "get_user"); + let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap(); + assert_eq!(result.value, "user-data"); + } + + // ── Test 8: ValidateAspect rejects required field missing ──────────────── + #[test] + fn test_validate_required_field() { + let aspect = ValidateAspect::new(); + aspect.add_rule("UserService", "create_user", "name", "required"); + let mut ctx = authed_ctx("UserService", "create_user"); + // No "name" arg + let result = aspect.before(&mut ctx); + assert!(matches!(result, Err(AopError::ValidationFailed(_)))); + } + + // ── Test 9: ValidateAspect passes when field is present ────────────────── + #[test] + fn test_validate_required_field_present() { + let aspect = ValidateAspect::new(); + aspect.add_rule("UserService", "create_user", "email", "email"); + let mut ctx = authed_ctx("UserService", "create_user") + .with_arg("email", "alice@example.com"); + assert!(aspect.before(&mut ctx).is_ok()); + } + + // ── Test 10: ValidateAspect rejects invalid email ───────────────────────── + #[test] + fn test_validate_email_rule() { + let aspect = ValidateAspect::new(); + aspect.add_rule("UserService", "create_user", "email", "email"); + let mut ctx = authed_ctx("UserService", "create_user") + .with_arg("email", "not-an-email"); + let result = aspect.before(&mut ctx); + assert!(matches!(result, Err(AopError::ValidationFailed(_)))); + } + + // ── Test 11: RetryAspect retries on failure ─────────────────────────────── + #[test] + fn test_retry_succeeds_on_third_attempt() { + let aspect = RetryAspect::new(3); + let ctx = authed_ctx("OrderService", "place_order"); + let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let attempt_clone = attempt.clone(); + let proceed: crate::ProceedFn = Box::new(move |_ctx| { + let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n < 2 { + Err(AopError::Aspect("transient error".into())) + } else { + Ok(InvocationResult::new("success")) + } + }); + let result = aspect.around(ctx, &proceed).unwrap(); + assert_eq!(result.value, "success"); + } + + // ── Test 12: RetryAspect exhausts all attempts ─────────────────────────── + #[test] + fn test_retry_exhausted() { + let aspect = RetryAspect::new(3); + let ctx = authed_ctx("OrderService", "place_order"); + let result = aspect.around(ctx, &fail_proceed("always fails")); + assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. }))); + } + + // ── Test 13: TraceAspect injects trace/span IDs ─────────────────────────── + #[test] + fn test_trace_aspect_injects_ids() { + let aspect = TraceAspect::new("el-ui"); + let ctx = authed_ctx("UserService", "get_user"); + let result = aspect.around(ctx, &succeed_proceed("data")).unwrap(); + assert!( + result.metadata.contains_key("trace_id"), + "should inject trace_id" + ); + assert!( + result.metadata.contains_key("span_id"), + "should inject span_id" + ); + } + + // ── Test 14: AspectChain executes aspects in order ──────────────────────── + #[test] + fn test_aspect_chain_ordering() { + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + + struct OrderTracker { + name: &'static str, + order: Arc>>, + } + impl Aspect for OrderTracker { + fn name(&self) -> &'static str { self.name } + fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult { + self.order.lock().unwrap().push(self.name); + proceed(ctx) + } + } + + let chain = AspectChain::new() + .add(Arc::new(OrderTracker { name: "first", order: order.clone() })) + .add(Arc::new(OrderTracker { name: "second", order: order.clone() })) + .add(Arc::new(OrderTracker { name: "third", order: order.clone() })); + + let ctx = authed_ctx("MyService", "my_method"); + chain.execute(ctx, succeed_proceed("ok")).unwrap(); + + let recorded = order.lock().unwrap(); + assert_eq!(*recorded, vec!["first", "second", "third"]); + } + + // ── Test 15: AspectChain with auth + authorize rejects unauthenticated ──── + #[test] + fn test_aspect_chain_auth_flow() { + let chain = AspectChain::new() + .add(Arc::new(AuthenticateAspect)) + .add(Arc::new(AuthorizeAspect::new("admin"))); + + // Unauthenticated — should fail at authenticate + let ctx = ctx("AdminDashboard", "load"); + let result = chain.execute(ctx, succeed_proceed("ok")); + assert!(matches!(result, Err(AopError::Unauthenticated))); + + // Authenticated but wrong role — should fail at authorize + let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user"); + let result = chain.execute(ctx, succeed_proceed("ok")); + assert!(matches!(result, Err(AopError::Forbidden { .. }))); + + // Admin — should succeed + let ctx = admin_ctx("AdminDashboard", "load"); + let result = chain.execute(ctx, succeed_proceed("ok")); + assert!(result.is_ok()); + } + + // ── Test 16: AspectRegistry registers all builtins ──────────────────────── + #[test] + fn test_registry_has_builtins() { + let registry = AspectRegistry::with_builtins(); + for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] { + assert!(registry.contains(name), "should have built-in: {}", name); + } + } + + // ── Test 17: AspectRegistry creates aspects from params ─────────────────── + #[test] + fn test_registry_creates_aspect() { + let registry = AspectRegistry::with_builtins(); + let mut params = HashMap::new(); + params.insert("role".into(), "admin".into()); + let aspect = registry.create("authorize", ¶ms); + assert!(aspect.is_some(), "should create authorize aspect"); + assert_eq!(aspect.unwrap().name(), "authorize"); + } + + // ── Test 18: AspectRegistry::create returns None for unknown aspect ─────── + #[test] + fn test_registry_unknown_aspect() { + let registry = AspectRegistry::with_builtins(); + let result = registry.create("unknown_aspect", &HashMap::new()); + assert!(result.is_none()); + } + + // ── Test 19: CacheAspect with zero TTL doesn't serve stale data ────────── + #[test] + fn test_cache_zero_ttl() { + let aspect = CacheAspect::new(0); // Immediate expiry + let ctx = authed_ctx("Service", "method"); + let n = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let nc = n.clone(); + // Both calls should hit proceed since ttl=0 means instant expiry + let p1: crate::ProceedFn = Box::new(move |_ctx| { + let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(InvocationResult::new(v.to_string())) + }); + // Zero TTL will expire immediately; we just verify it doesn't panic + let _ = aspect.around(ctx.clone(), &p1); + // Second call should also execute proceed + let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh"))); + let r = aspect.around(ctx, &p2).unwrap(); + assert_eq!(r.value, "fresh"); + } + + // ── Test 20: Empty AspectChain calls proceed directly ───────────────────── + #[test] + fn test_empty_chain_calls_proceed() { + let chain = AspectChain::new(); + assert!(chain.is_empty()); + let ctx = authed_ctx("Service", "method"); + let result = chain.execute(ctx, succeed_proceed("direct")).unwrap(); + assert_eq!(result.value, "direct"); + } +} diff --git a/ui/crates/el-auth/Cargo.toml b/ui/crates/el-auth/Cargo.toml new file mode 100644 index 0000000..9de612c --- /dev/null +++ b/ui/crates/el-auth/Cargo.toml @@ -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] diff --git a/ui/crates/el-auth/src/context.rs b/ui/crates/el-auth/src/context.rs new file mode 100644 index 0000000..5cec55b --- /dev/null +++ b/ui/crates/el-auth/src/context.rs @@ -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, + email: impl Into, + name: impl Into, + ) -> Self { + Self { + id: id.into(), + email: email.into(), + name: name.into(), + } + } +} + +/// The auth context — populated by `AuthMiddleware` and available to all +/// components and services downstream in the request. +/// +/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP +/// layer (see `el-aop`). +#[derive(Debug, Clone)] +pub struct AuthContext { + pub user: Option, + pub roles: Vec, + pub permissions: Vec, + /// The raw token/session ID that was verified. + pub token: String, +} + +impl AuthContext { + pub fn anonymous() -> Self { + Self { + user: None, + roles: Vec::new(), + permissions: Vec::new(), + token: String::new(), + } + } + + pub fn authenticated(user: AuthUser, roles: Vec, token: impl Into) -> Self { + Self { + user: Some(user), + roles, + permissions: Vec::new(), + token: token.into(), + } + } + + pub fn with_permissions(mut self, perms: Vec) -> Self { + self.permissions = perms; + self + } + + pub fn is_authenticated(&self) -> bool { + self.user.is_some() + } + + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } + + pub fn has_permission(&self, permission: &str) -> bool { + self.permissions.iter().any(|p| p == permission) + } + + pub fn user_id(&self) -> Option<&str> { + self.user.as_ref().map(|u| u.id.as_str()) + } +} diff --git a/ui/crates/el-auth/src/jwt.rs b/ui/crates/el-auth/src/jwt.rs new file mode 100644 index 0000000..aa045ee --- /dev/null +++ b/ui/crates/el-auth/src/jwt.rs @@ -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; + +/// JWT claims payload. +#[derive(Debug, Clone)] +pub struct JwtClaims { + pub sub: String, // user ID + pub email: String, + pub name: String, + pub roles: Vec, + pub iat: u64, // issued-at (unix seconds) + pub exp: u64, // expiry (unix seconds) +} + +impl JwtClaims { + pub fn new(user: &AuthUser, roles: Vec, ttl_seconds: u64) -> Self { + let now = unix_now(); + Self { + sub: user.id.clone(), + email: user.email.clone(), + name: user.name.clone(), + roles, + 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::>() + .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 { + 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, + /// Token TTL in seconds (default: 3600 = 1 hour). + pub ttl_seconds: u64, +} + +impl JwtProvider { + pub fn new(secret: impl Into>) -> Self { + Self { secret: secret.into(), ttl_seconds: 3600 } + } + + pub fn from_env(env_var: &str) -> AuthResult { + let secret = std::env::var(env_var).map_err(|_| { + AuthError::Config(format!("env var {} not set", env_var)) + })?; + Ok(Self::new(secret.into_bytes())) + } + + pub fn with_ttl(mut self, seconds: u64) -> Self { + self.ttl_seconds = seconds; + self + } + + /// Sign a token with HMAC-SHA256. + fn sign(&self, header_payload: &str) -> String { + let mut mac = HmacSha256::new_from_slice(&self.secret) + .expect("HMAC can take key of any size"); + mac.update(header_payload.as_bytes()); + let result = mac.finalize(); + base64url_encode(&result.into_bytes()) + } + + /// Encode a JWT token from claims. + pub fn encode(&self, claims: &JwtClaims) -> String { + let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); + let payload = base64url_encode(claims.to_json().as_bytes()); + let header_payload = format!("{}.{}", header, payload); + let signature = self.sign(&header_payload); + format!("{}.{}", header_payload, signature) + } + + /// Decode and verify a JWT token. + pub fn decode(&self, token: &str) -> AuthResult { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AuthError::TokenInvalid("not a valid JWT".into())); + } + + let header_payload = format!("{}.{}", parts[0], parts[1]); + let expected_sig = self.sign(&header_payload); + if !constant_time_eq(parts[2], &expected_sig) { + return Err(AuthError::TokenInvalid("signature mismatch".into())); + } + + let payload_bytes = base64url_decode(parts[1]) + .ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?; + let payload_str = String::from_utf8(payload_bytes) + .map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?; + + let claims = JwtClaims::from_json(&payload_str) + .ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?; + + if claims.is_expired() { + return Err(AuthError::TokenExpired); + } + + Ok(claims) + } +} + +impl AuthProvider for JwtProvider { + fn name(&self) -> &'static str { + "jwt" + } + + fn verify(&self, token: &str) -> AuthResult { + let claims = self.decode(token)?; + let user = AuthUser::new(&claims.sub, &claims.email, &claims.name); + Ok(AuthContext::authenticated(user, claims.roles, token)) + } + + fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult { + let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds); + Ok(self.encode(&claims)) + } + + fn revoke(&self, _token: &str) -> AuthResult<()> { + // JWTs are stateless — revocation requires a blocklist. + // TODO: maintain a revocation list (in-memory or Redis). + Ok(()) + } +} + +// ── Crypto helpers ───────────────────────────────────────────────────────────── + +fn base64url_encode(input: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(CHARS[((n >> 18) & 63) as usize] as char); + out.push(CHARS[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(CHARS[((n >> 6) & 63) as usize] as char); + } + if chunk.len() > 2 { + out.push(CHARS[(n & 63) as usize] as char); + } + } + out +} + +fn base64url_decode(input: &str) -> Option> { + // Pad if needed + let mut s = input.replace('-', "+").replace('_', "/"); + while s.len() % 4 != 0 { + s.push('='); + } + base64_decode_standard(&s) +} + +fn base64_decode_standard(input: &str) -> Option> { + const TABLE: [u8; 128] = { + let mut t = [255u8; 128]; + let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut i = 0usize; + while i < chars.len() { + t[chars[i] as usize] = i as u8; + i += 1; + } + t + }; + + let input = input.trim_end_matches('='); + let mut out = Vec::new(); + let bytes = input.as_bytes(); + let mut i = 0; + while i + 3 < bytes.len() { + let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; + let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; + let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?; + let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?; + let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32); + out.push((n >> 16) as u8); + out.push((n >> 8) as u8); + out.push(n as u8); + i += 4; + } + // Handle remaining bytes + if i + 2 == bytes.len() { + let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; + let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; + out.push(((a as u32) << 2 | (b as u32) >> 4) as u8); + } else if i + 3 == bytes.len() { + let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?; + let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?; + let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?; + let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2); + out.push((n >> 8) as u8); + out.push(n as u8); + } + Some(out) +} + +fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ── Minimal JSON field extractors ───────────────────────────────────────────── + +fn extract_str(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":\"", key); + let start = json.find(&pattern)? + pattern.len(); + let rest = &json[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +fn extract_u64(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + let start = json.find(&pattern)? + pattern.len(); + let rest = &json[start..]; + let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + rest[..end].parse().ok() +} + +fn extract_str_array(json: &str, key: &str) -> Vec { + let pattern = format!("\"{}\":[", key); + let start = match json.find(&pattern) { + None => return Vec::new(), + Some(s) => s + pattern.len(), + }; + let rest = &json[start..]; + let end = rest.find(']').unwrap_or(rest.len()); + let content = &rest[..end]; + content + .split(',') + .filter_map(|s| { + let s = s.trim().trim_matches('"'); + if s.is_empty() { None } else { Some(s.to_string()) } + }) + .collect() +} diff --git a/ui/crates/el-auth/src/lib.rs b/ui/crates/el-auth/src/lib.rs new file mode 100644 index 0000000..42d1a3b --- /dev/null +++ b/ui/crates/el-auth/src/lib.rs @@ -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 = Result; + +/// The AuthProvider trait — implemented by JWT, Session, OAuth providers. +pub trait AuthProvider: Send + Sync { + /// The provider name (e.g., "jwt", "session"). + fn name(&self) -> &'static str; + + /// Verify a token/session string and return the auth context. + fn verify(&self, token: &str) -> AuthResult; + + /// Issue a new token/session for an authenticated user. + fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult; + + /// Revoke a token/session (for logout). + fn revoke(&self, token: &str) -> AuthResult<()>; +} diff --git a/ui/crates/el-auth/src/middleware.rs b/ui/crates/el-auth/src/middleware.rs new file mode 100644 index 0000000..a67a530 --- /dev/null +++ b/ui/crates/el-auth/src/middleware.rs @@ -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, +} + +impl AuthMiddleware { + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + /// Extract and verify the auth token from an Authorization header value. + /// + /// Supported formats: + /// - `Bearer ` — JWT or opaque token + /// - `Session ` — server-side session + pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult { + match authorization { + None => Ok(AuthContext::anonymous()), + Some(header) => { + let token = if let Some(t) = header.strip_prefix("Bearer ") { + t.trim() + } else if let Some(t) = header.strip_prefix("Session ") { + t.trim() + } else { + header.trim() + }; + self.provider.verify(token) + } + } + } + + /// Authenticate from a query parameter (for WebSocket upgrades where + /// Authorization headers can't be set from JS). + pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult { + match token { + None => Ok(AuthContext::anonymous()), + Some(t) => self.provider.verify(t), + } + } + + /// Get the underlying provider name. + pub fn provider_name(&self) -> &'static str { + self.provider.name() + } +} diff --git a/ui/crates/el-auth/src/roles.rs b/ui/crates/el-auth/src/roles.rs new file mode 100644 index 0000000..041e06c --- /dev/null +++ b/ui/crates/el-auth/src/roles.rs @@ -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) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Permission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A role that grants a set of permissions. +#[derive(Debug, Clone)] +pub struct Role { + pub name: String, + pub permissions: Vec, +} + +impl Role { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + permissions: Vec::new(), + } + } + + pub fn with_permission(mut self, perm: impl Into) -> Self { + self.permissions.push(Permission::new(perm)); + self + } + + pub fn with_permissions(mut self, perms: Vec>) -> Self { + self.permissions.extend(perms.into_iter().map(Permission::new)); + self + } + + pub fn has_permission(&self, perm: &str) -> bool { + self.permissions.iter().any(|p| p.0 == perm) + } +} + +/// The registry of all roles in the application. +#[derive(Debug, Default)] +pub struct RoleRegistry { + roles: HashMap, +} + +impl RoleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a role. + pub fn register(&mut self, role: Role) { + self.roles.insert(role.name.clone(), role); + } + + /// Get a role by name. + pub fn get(&self, name: &str) -> Option<&Role> { + self.roles.get(name) + } + + /// Check if the given role names grant the given permission. + pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool { + role_names.iter().any(|role_name| { + self.roles + .get(role_name) + .map(|r| r.has_permission(permission)) + .unwrap_or(false) + }) + } + + /// List all registered role names. + pub fn role_names(&self) -> Vec<&str> { + self.roles.keys().map(|s| s.as_str()).collect() + } +} diff --git a/ui/crates/el-auth/src/session.rs b/ui/crates/el-auth/src/session.rs new file mode 100644 index 0000000..90966fe --- /dev/null +++ b/ui/crates/el-auth/src/session.rs @@ -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>, + pub ttl: Duration, +} + +impl SessionProvider { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + ttl: Duration::from_secs(3600), + } + } + + pub fn with_ttl(mut self, seconds: u64) -> Self { + self.ttl = Duration::from_secs(seconds); + self + } + + fn generate_session_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890) + } + + /// Count active (non-expired) sessions. + pub fn active_session_count(&self) -> usize { + let sessions = self.sessions.lock().expect("session lock poisoned"); + sessions.values().filter(|s| !s.is_expired()).count() + } +} + +impl Default for SessionProvider { + fn default() -> Self { + Self::new() + } +} + +impl AuthProvider for SessionProvider { + fn name(&self) -> &'static str { + "session" + } + + fn verify(&self, session_id: &str) -> AuthResult { + let mut sessions = self.sessions.lock().expect("session lock poisoned"); + // Clean expired sessions + sessions.retain(|_, v| !v.is_expired()); + sessions + .get(session_id) + .filter(|s| !s.is_expired()) + .map(|s| s.context.clone()) + .ok_or(AuthError::SessionNotFound) + } + + fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult { + let session_id = Self::generate_session_id(); + let ctx = AuthContext::authenticated(user, Vec::new(), &session_id); + let entry = SessionEntry { + context: ctx, + created_at: Instant::now(), + ttl: self.ttl, + }; + self.sessions + .lock() + .expect("session lock poisoned") + .insert(session_id.clone(), entry); + Ok(session_id) + } + + fn revoke(&self, session_id: &str) -> AuthResult<()> { + self.sessions + .lock() + .expect("session lock poisoned") + .remove(session_id); + Ok(()) + } +} diff --git a/ui/crates/el-auth/src/tests.rs b/ui/crates/el-auth/src/tests.rs new file mode 100644 index 0000000..c14a4e4 --- /dev/null +++ b/ui/crates/el-auth/src/tests.rs @@ -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, ®).unwrap(); + let ctx = provider.verify(&token).unwrap(); + assert!(ctx.is_authenticated()); + assert_eq!(ctx.user_id().unwrap(), "user-1"); + } + + // ── Test 5: AuthContext::has_role works ────────────────────────────────── + #[test] + fn test_auth_context_has_role() { + let ctx = AuthContext::authenticated( + test_user(), + vec!["admin".into(), "user".into()], + "tok", + ); + assert!(ctx.has_role("admin")); + assert!(ctx.has_role("user")); + assert!(!ctx.has_role("superadmin")); + } + + // ── Test 6: AuthContext::has_permission works ───────────────────────────── + #[test] + fn test_auth_context_has_permission() { + let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok") + .with_permissions(vec!["read".into(), "write".into(), "delete".into()]); + assert!(ctx.has_permission("read")); + assert!(ctx.has_permission("delete")); + assert!(!ctx.has_permission("sudo")); + } + + // ── Test 7: AuthContext::anonymous is not authenticated ─────────────────── + #[test] + fn test_anonymous_context() { + let ctx = AuthContext::anonymous(); + assert!(!ctx.is_authenticated()); + assert!(ctx.user_id().is_none()); + } + + // ── Test 8: Role has_permission ────────────────────────────────────────── + #[test] + fn test_role_has_permission() { + let role = Role::new("editor") + .with_permissions(vec!["read", "write"]); + assert!(role.has_permission("read")); + assert!(role.has_permission("write")); + assert!(!role.has_permission("delete")); + } + + // ── Test 9: RoleRegistry::has_permission checks across roles ───────────── + #[test] + fn test_role_registry_permission_check() { + let reg = registry(); + let roles = vec!["user".to_string()]; + assert!(reg.has_permission(&roles, "read")); + assert!(!reg.has_permission(&roles, "delete")); + + let admin_roles = vec!["admin".to_string()]; + assert!(reg.has_permission(&admin_roles, "delete")); + } + + // ── Test 10: SessionProvider issue and verify ───────────────────────────── + #[test] + fn test_session_issue_and_verify() { + let provider = SessionProvider::new(); + let reg = registry(); + let session_id = provider.issue(test_user(), ®).unwrap(); + let ctx = provider.verify(&session_id).unwrap(); + assert!(ctx.is_authenticated()); + assert_eq!(ctx.user_id().unwrap(), "user-1"); + } + + // ── Test 11: SessionProvider revoke removes session ─────────────────────── + #[test] + fn test_session_revoke() { + let provider = SessionProvider::new(); + let reg = registry(); + let session_id = provider.issue(test_user(), ®).unwrap(); + provider.revoke(&session_id).unwrap(); + let result = provider.verify(&session_id); + assert!(matches!(result, Err(AuthError::SessionNotFound))); + } + + // ── Test 12: SessionProvider unknown session returns error ──────────────── + #[test] + fn test_session_unknown() { + let provider = SessionProvider::new(); + let result = provider.verify("nonexistent-session-id"); + assert!(matches!(result, Err(AuthError::SessionNotFound))); + } + + // ── Test 13: AuthMiddleware extracts Bearer token ───────────────────────── + #[test] + fn test_middleware_extracts_bearer_token() { + let provider = Arc::new(test_provider()); + let user = test_user(); + let claims = JwtClaims::new(&user, vec!["user".into()], 3600); + let token = provider.encode(&claims); + let middleware = AuthMiddleware::new(provider); + let header = format!("Bearer {}", token); + let ctx = middleware.authenticate_from_header(Some(&header)).unwrap(); + assert!(ctx.is_authenticated()); + } + + // ── Test 14: AuthMiddleware with no header returns anonymous ────────────── + #[test] + fn test_middleware_no_header_anonymous() { + let provider = Arc::new(test_provider()); + let middleware = AuthMiddleware::new(provider); + let ctx = middleware.authenticate_from_header(None).unwrap(); + assert!(!ctx.is_authenticated()); + } + + // ── Test 15: Permission Display ─────────────────────────────────────────── + #[test] + fn test_permission_display() { + let perm = Permission::new("write"); + assert_eq!(perm.to_string(), "write"); + assert_eq!(perm.as_str(), "write"); + } + + // ── Test 16: JwtClaims::to_json and from_json round-trip ───────────────── + #[test] + fn test_jwt_claims_json_round_trip() { + let user = test_user(); + let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600); + let json = claims.to_json(); + let decoded = JwtClaims::from_json(&json).unwrap(); + assert_eq!(decoded.sub, "user-1"); + assert_eq!(decoded.email, "alice@example.com"); + assert_eq!(decoded.roles, vec!["admin", "user"]); + } + + // ── Test 17: JWT with multiple roles ───────────────────────────────────── + #[test] + fn test_jwt_multiple_roles() { + let provider = test_provider(); + let user = test_user(); + let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600); + let token = provider.encode(&claims); + let decoded = provider.decode(&token).unwrap(); + assert_eq!(decoded.roles.len(), 2); + assert!(decoded.roles.contains(&"admin".to_string())); + } + + // ── Test 18: RoleRegistry::role_names lists all roles ──────────────────── + #[test] + fn test_role_registry_names() { + let reg = registry(); + let mut names = reg.role_names(); + names.sort(); + assert_eq!(names, vec!["admin", "user"]); + } + + // ── Test 19: SessionProvider TTL configuration ──────────────────────────── + #[test] + fn test_session_ttl_config() { + let provider = SessionProvider::new().with_ttl(7200); + assert_eq!(provider.ttl.as_secs(), 7200); + } + + // ── Test 20: AuthMiddleware provider_name returns correct name ──────────── + #[test] + fn test_middleware_provider_name() { + let jwt_provider = Arc::new(test_provider()); + let middleware = AuthMiddleware::new(jwt_provider); + assert_eq!(middleware.provider_name(), "jwt"); + } +} diff --git a/ui/crates/el-platform/Cargo.toml b/ui/crates/el-platform/Cargo.toml new file mode 100644 index 0000000..6b16a46 --- /dev/null +++ b/ui/crates/el-platform/Cargo.toml @@ -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] diff --git a/ui/crates/el-platform/src/backends/android.rs b/ui/crates/el-platform/src/backends/android.rs new file mode 100644 index 0000000..ad5b3dc --- /dev/null +++ b/ui/crates/el-platform/src/backends/android.rs @@ -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 { + let mut node = PlatformNode::element(tag); + // TODO: JNI call to create the Android view: + // env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...]) + node.attributes.push(crate::Attribute::new( + "data-android-class", + Self::android_view_class(tag), + )); + Ok(node) + } + + fn create_text(&self, content: &str) -> PlatformResult { + let mut node = PlatformNode::text(content); + // TODO: JNI: create TextView, set text = content + node.attributes + .push(crate::Attribute::new("data-android-class", "android.widget.TextView")); + Ok(node) + } + + fn set_attribute( + &self, + node: &mut PlatformNode, + name: &str, + value: &str, + ) -> PlatformResult<()> { + // TODO: map attribute to Java property setter via JNI + // "class" → setBackground / setTextAppearance + // "disabled" → setEnabled(false) + // "placeholder" → setHint(value) + node.attributes.retain(|a| a.name != name); + node.attributes.push(crate::Attribute::new(name, value)); + Ok(()) + } + + fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + Ok(()) + } + + fn append_child( + &self, + parent: &mut PlatformNode, + child: PlatformNode, + ) -> PlatformResult<()> { + // TODO: JNI: ((ViewGroup) parent).addView(child) + parent.children.push(child); + Ok(()) + } + + fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { + if child_index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "android: child index {} out of bounds", + child_index + ))); + } + // TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index) + parent.children.remove(child_index); + Ok(()) + } + + fn replace_child( + &self, + parent: &mut PlatformNode, + index: usize, + new_child: PlatformNode, + ) -> PlatformResult<()> { + if index >= parent.children.len() { + return Err(PlatformError::Render( + "android: replace_child out of bounds".into(), + )); + } + // TODO: JNI: remove old view, add new view at index + parent.children[index] = new_child; + Ok(()) + } + + fn bind_event( + &self, + node: &mut PlatformNode, + event: &str, + _handler: EventHandler, + ) -> PlatformResult<()> { + let listener = Self::android_event_listener(event); + // TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... }) + node.attributes.push(crate::Attribute::new( + format!("data-android-event-{}", event), + listener, + )); + Ok(()) + } + + fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { + Ok(node.to_html()) + } + + fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { + // TODO: get Activity by container_id, set root as content view + Ok(()) + } + + fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { + // TODO: diff and apply JNI mutations + Ok(()) + } +} diff --git a/ui/crates/el-platform/src/backends/ios.rs b/ui/crates/el-platform/src/backends/ios.rs new file mode 100644 index 0000000..46cc62d --- /dev/null +++ b/ui/crates/el-platform/src/backends/ios.rs @@ -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 { + let mut node = PlatformNode::element(tag); + // TODO: call UIKit C FFI to allocate the view: + // extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; } + // node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) }); + let uikit_class = Self::uikit_class(tag); + node.attributes + .push(crate::Attribute::new("data-uikit-class", uikit_class)); + Ok(node) + } + + fn create_text(&self, content: &str) -> PlatformResult { + let mut node = PlatformNode::text(content); + // TODO: UILabel with text = content + node.attributes + .push(crate::Attribute::new("data-uikit-class", "UILabel")); + Ok(node) + } + + fn set_attribute( + &self, + node: &mut PlatformNode, + name: &str, + value: &str, + ) -> PlatformResult<()> { + // TODO: map attribute names to UIKit property setters: + // "class" → apply style from stylesheet + // "disabled" → view.isUserInteractionEnabled = false + // "placeholder" → textField.placeholder = value + node.attributes.retain(|a| a.name != name); + node.attributes.push(crate::Attribute::new(name, value)); + Ok(()) + } + + fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + Ok(()) + } + + fn append_child( + &self, + parent: &mut PlatformNode, + child: PlatformNode, + ) -> PlatformResult<()> { + // TODO: [parentView addSubview:childView] via FFI + parent.children.push(child); + Ok(()) + } + + fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { + if child_index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "ios: child index {} out of bounds", + child_index + ))); + } + // TODO: [childView removeFromSuperview] via FFI + parent.children.remove(child_index); + Ok(()) + } + + fn replace_child( + &self, + parent: &mut PlatformNode, + index: usize, + new_child: PlatformNode, + ) -> PlatformResult<()> { + if index >= parent.children.len() { + return Err(PlatformError::Render("ios: replace_child out of bounds".into())); + } + // TODO: remove old view, insert new view via FFI + parent.children[index] = new_child; + Ok(()) + } + + fn bind_event( + &self, + node: &mut PlatformNode, + event: &str, + _handler: EventHandler, + ) -> PlatformResult<()> { + let uievent = Self::uicontrol_event(event); + // TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent] + node.attributes + .push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent)); + Ok(()) + } + + fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { + // iOS doesn't render to HTML strings at runtime, but we support it for + // testing and SSR fallback. + Ok(node.to_html()) + } + + fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { + // TODO: get UIViewController by container_id and set root as its view + Ok(()) + } + + fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { + // TODO: diff old and new trees, apply UIKit mutations via FFI + Ok(()) + } +} diff --git a/ui/crates/el-platform/src/backends/linux.rs b/ui/crates/el-platform/src/backends/linux.rs new file mode 100644 index 0000000..e886c39 --- /dev/null +++ b/ui/crates/el-platform/src/backends/linux.rs @@ -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 { + let mut node = PlatformNode::element(tag); + // TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI + node.attributes + .push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag))); + Ok(node) + } + + fn create_text(&self, content: &str) -> PlatformResult { + let mut node = PlatformNode::text(content); + // TODO: gtk_label_new(content) + node.attributes + .push(crate::Attribute::new("data-gtk-widget", "GtkLabel")); + Ok(node) + } + + fn set_attribute( + &self, + node: &mut PlatformNode, + name: &str, + value: &str, + ) -> PlatformResult<()> { + // TODO: map to GTK property setters + // "class" → gtk_widget_add_css_class + // "disabled" → gtk_widget_set_sensitive(false) + // "placeholder" → gtk_entry_set_placeholder_text + node.attributes.retain(|a| a.name != name); + node.attributes.push(crate::Attribute::new(name, value)); + Ok(()) + } + + fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + Ok(()) + } + + fn append_child( + &self, + parent: &mut PlatformNode, + child: PlatformNode, + ) -> PlatformResult<()> { + // TODO: gtk_box_append(parent, child) + parent.children.push(child); + Ok(()) + } + + fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { + if child_index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "linux: child index {} out of bounds", + child_index + ))); + } + // TODO: gtk_widget_unparent(child) + parent.children.remove(child_index); + Ok(()) + } + + fn replace_child( + &self, + parent: &mut PlatformNode, + index: usize, + new_child: PlatformNode, + ) -> PlatformResult<()> { + if index >= parent.children.len() { + return Err(PlatformError::Render("linux: replace_child out of bounds".into())); + } + parent.children[index] = new_child; + Ok(()) + } + + fn bind_event( + &self, + node: &mut PlatformNode, + event: &str, + _handler: EventHandler, + ) -> PlatformResult<()> { + let signal = Self::gtk_signal(event); + // TODO: g_signal_connect(widget, signal, callback, data) + node.attributes + .push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal)); + Ok(()) + } + + fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { + Ok(node.to_html()) + } + + fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { + // TODO: get GtkWindow and call gtk_window_set_child(root) + Ok(()) + } + + fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { + // TODO: diff and apply GTK mutations + Ok(()) + } +} diff --git a/ui/crates/el-platform/src/backends/macos.rs b/ui/crates/el-platform/src/backends/macos.rs new file mode 100644 index 0000000..fc245fd --- /dev/null +++ b/ui/crates/el-platform/src/backends/macos.rs @@ -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 { + let mut node = PlatformNode::element(tag); + // TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; } + node.attributes + .push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag))); + Ok(node) + } + + fn create_text(&self, content: &str) -> PlatformResult { + let mut node = PlatformNode::text(content); + // TODO: NSTextField in label mode with stringValue = content + node.attributes + .push(crate::Attribute::new("data-appkit-class", "NSTextField")); + Ok(node) + } + + fn set_attribute( + &self, + node: &mut PlatformNode, + name: &str, + value: &str, + ) -> PlatformResult<()> { + // TODO: map to AppKit property setters + node.attributes.retain(|a| a.name != name); + node.attributes.push(crate::Attribute::new(name, value)); + Ok(()) + } + + fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + Ok(()) + } + + fn append_child( + &self, + parent: &mut PlatformNode, + child: PlatformNode, + ) -> PlatformResult<()> { + // TODO: [parentView addSubview:childView] + parent.children.push(child); + Ok(()) + } + + fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { + if child_index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "macos: child index {} out of bounds", + child_index + ))); + } + // TODO: [childView removeFromSuperview] + parent.children.remove(child_index); + Ok(()) + } + + fn replace_child( + &self, + parent: &mut PlatformNode, + index: usize, + new_child: PlatformNode, + ) -> PlatformResult<()> { + if index >= parent.children.len() { + return Err(PlatformError::Render("macos: replace_child out of bounds".into())); + } + parent.children[index] = new_child; + Ok(()) + } + + fn bind_event( + &self, + node: &mut PlatformNode, + event: &str, + _handler: EventHandler, + ) -> PlatformResult<()> { + // TODO: NSButton.target + NSButton.action pattern via FFI + node.attributes + .push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound")); + Ok(()) + } + + fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { + Ok(node.to_html()) + } + + fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { + // TODO: find NSWindow or NSViewController and set root as contentView + Ok(()) + } + + fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { + // TODO: diff and apply AppKit mutations + Ok(()) + } +} diff --git a/ui/crates/el-platform/src/backends/mod.rs b/ui/crates/el-platform/src/backends/mod.rs new file mode 100644 index 0000000..e8b9bfa --- /dev/null +++ b/ui/crates/el-platform/src/backends/mod.rs @@ -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; diff --git a/ui/crates/el-platform/src/backends/server.rs b/ui/crates/el-platform/src/backends/server.rs new file mode 100644 index 0000000..d6e0904 --- /dev/null +++ b/ui/crates/el-platform/src/backends/server.rs @@ -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 { + let body = self.render_to_string(node)?; + Ok(format!( + r#" + + + + + {title} + + +
+ {body} +
+ + +"# + )) + } +} + +impl Default for ServerBackend { + fn default() -> Self { + Self::new() + } +} + +impl PlatformBackend for ServerBackend { + fn name(&self) -> &'static str { + "server" + } + + fn create_element(&self, tag: &str) -> PlatformResult { + Ok(PlatformNode::element(tag)) + } + + fn create_text(&self, content: &str) -> PlatformResult { + Ok(PlatformNode::text(content)) + } + + fn set_attribute( + &self, + node: &mut PlatformNode, + name: &str, + value: &str, + ) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + node.attributes.push(crate::Attribute::new(name, value)); + Ok(()) + } + + fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> { + node.attributes.retain(|a| a.name != name); + Ok(()) + } + + fn append_child( + &self, + parent: &mut PlatformNode, + child: PlatformNode, + ) -> PlatformResult<()> { + parent.children.push(child); + Ok(()) + } + + fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> { + if child_index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "server: child index {} out of bounds", + child_index + ))); + } + parent.children.remove(child_index); + Ok(()) + } + + fn replace_child( + &self, + parent: &mut PlatformNode, + index: usize, + new_child: PlatformNode, + ) -> PlatformResult<()> { + if index >= parent.children.len() { + return Err(PlatformError::Render(format!( + "server: replace_child index {} out of bounds", + index + ))); + } + parent.children[index] = new_child; + Ok(()) + } + + fn bind_event( + &self, + node: &mut PlatformNode, + event: &str, + _handler: EventHandler, + ) -> PlatformResult<()> { + // On the server, event handlers are emitted as data attributes. + // The client-side hydration pass picks them up and binds real listeners. + if self.hydration_markers { + node.attributes + .push(crate::Attribute::new(format!("data-el-{}", event), "hydrate")); + } + Ok(()) + } + + fn render_to_string(&self, node: &PlatformNode) -> PlatformResult { + Ok(node.to_html()) + } + + fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> { + // Server has no mount concept — rendering is one-shot. + Ok(()) + } + + fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> { + // Server rendering is stateless — no patch needed. + Ok(()) + } + + fn supports_ssr(&self) -> bool { + true + } +} diff --git a/ui/crates/el-platform/src/backends/tests.rs b/ui/crates/el-platform/src/backends/tests.rs new file mode 100644 index 0000000..79472e8 --- /dev/null +++ b/ui/crates/el-platform/src/backends/tests.rs @@ -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(""), "should close div tag"); + } + + // ── Test 7: ServerBackend supports SSR ─────────────────────────────────── + #[test] + fn test_server_backend_supports_ssr() { + let backend = ServerBackend::new(); + assert!(backend.supports_ssr()); + } + + // ── Test 8: ServerBackend renders full HTML page ───────────────────────── + #[test] + fn test_server_backend_render_page() { + let backend = ServerBackend::new(); + let root = PlatformNode::element("div") + .with_attr("id", "content") + .with_child(PlatformNode::text("Hello World")); + + let page = backend.render_page(&root, "My App", "/app.js").unwrap(); + assert!(page.contains(""), "should be full HTML doc"); + assert!(page.contains("My App"), "should include title"); + assert!(page.contains("Hello World"), "should include content"); + assert!(page.contains("/app.js"), "should include script src"); + } + + // ── Test 9: PlatformNode::to_html renders nested tree ──────────────────── + #[test] + fn test_platform_node_to_html_nested() { + let node = PlatformNode::element("ul") + .with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1"))) + .with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2"))); + + let html = node.to_html(); + assert!(html.contains("
    ")); + assert!(html.contains("
  • item 1
  • ")); + assert!(html.contains("
  • item 2
  • ")); + assert!(html.contains("
")); + } + + // ── Test 10: PlatformNode::to_html escapes text content ────────────────── + #[test] + fn test_html_escaping() { + let node = PlatformNode::element("span") + .with_child(PlatformNode::text("")); + let html = node.to_html(); + assert!(!html.contains("