feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-05 04:19:22 -05:00
parent b580a63540
commit faee6fdb25
145 changed files with 4050 additions and 12 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-aop"
version = "0.1.0"
edition = "2021"
description = "el-ui aspect-oriented programming — cross-cutting concerns as first-class features"
license = "MIT"
[lib]
name = "el_aop"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+22
View File
@@ -0,0 +1,22 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Cross-cutting concerns as first-class language features. Decorators applied
// to components and methods. @authenticate is the default; @public is the
// explicit opt-out.
vessel "el-aop" {
version "0.1.0"
description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..."
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-auth "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+490
View File
@@ -0,0 +1,490 @@
//! Built-in aspects for el-ui.
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Mutex,
},
time::{Duration, Instant},
};
use crate::{AopError, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
// ── @authenticate ─────────────────────────────────────────────────────────────
/// `@authenticate` — Requires a valid session before the method executes.
///
/// Checks `ctx.metadata["session_token"]` or `ctx.metadata["user_id"]`.
/// If absent, rejects with `AopError::Unauthenticated`.
pub struct AuthenticateAspect;
impl Aspect for AuthenticateAspect {
fn name(&self) -> &'static str {
"authenticate"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
// Look for a session token or user ID in metadata.
// In production, the auth middleware populates these from the JWT/session.
let has_user = ctx.metadata.contains_key("user_id")
|| ctx.metadata.contains_key("session_token");
if !has_user {
return Err(AopError::Unauthenticated);
}
Ok(())
}
}
// ── @authorize ────────────────────────────────────────────────────────────────
/// `@authorize(role: "admin")` — Requires the caller to have a specific role.
pub struct AuthorizeAspect {
pub required_role: String,
}
impl AuthorizeAspect {
pub fn new(role: impl Into<String>) -> Self {
Self { required_role: role.into() }
}
}
impl Aspect for AuthorizeAspect {
fn name(&self) -> &'static str {
"authorize"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let user_roles = ctx
.metadata
.get("roles")
.map(|s| s.as_str())
.unwrap_or("");
// Roles are stored as comma-separated string: "admin,user"
let has_role = user_roles
.split(',')
.any(|r| r.trim() == self.required_role.as_str());
if !has_role {
return Err(AopError::Forbidden {
role: self.required_role.clone(),
});
}
Ok(())
}
}
// ── @cache ────────────────────────────────────────────────────────────────────
struct CacheEntry {
value: InvocationResult,
inserted_at: Instant,
ttl: Duration,
}
impl CacheEntry {
fn is_expired(&self) -> bool {
self.inserted_at.elapsed() > self.ttl
}
}
/// `@cache(ttl: 300)` — Cache method responses for `ttl` seconds.
///
/// Cache key is `"target::method::{args_sorted_json}"`.
pub struct CacheAspect {
pub ttl: Duration,
cache: Mutex<HashMap<String, CacheEntry>>,
}
impl CacheAspect {
pub fn new(ttl_seconds: u64) -> Self {
Self {
ttl: Duration::from_secs(ttl_seconds),
cache: Mutex::new(HashMap::new()),
}
}
fn cache_key(ctx: &InvocationContext) -> String {
let mut pairs: Vec<(&String, &String)> = ctx.args.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
let args = pairs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",");
format!("{}::{}::{}", ctx.target, ctx.method, args)
}
}
impl Aspect for CacheAspect {
fn name(&self) -> &'static str {
"cache"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
let key = Self::cache_key(&ctx);
// Check cache
{
let cache = self.cache.lock().expect("cache lock poisoned");
if let Some(entry) = cache.get(&key) {
if !entry.is_expired() {
return Ok(entry.value.clone());
}
}
}
// Cache miss — proceed and store result
let result = proceed(ctx)?;
{
let mut cache = self.cache.lock().expect("cache lock poisoned");
// Evict expired entries while we're here
cache.retain(|_, v| !v.is_expired());
cache.insert(
key,
CacheEntry {
value: result.clone(),
inserted_at: Instant::now(),
ttl: self.ttl,
},
);
}
Ok(result)
}
}
// ── @rate_limit ───────────────────────────────────────────────────────────────
struct RateWindow {
count: u32,
window_start: Instant,
window_duration: Duration,
}
/// `@rate_limit(requests: 100, per: 60)` — Allow at most `requests` calls per `per` seconds.
pub struct RateLimitAspect {
pub max_requests: u32,
pub window: Duration,
state: Mutex<HashMap<String, RateWindow>>,
}
impl RateLimitAspect {
pub fn new(max_requests: u32, per_seconds: u64) -> Self {
Self {
max_requests,
window: Duration::from_secs(per_seconds),
state: Mutex::new(HashMap::new()),
}
}
/// The rate-limit key for a caller. Uses `user_id` or "anonymous".
fn caller_key(ctx: &InvocationContext) -> String {
ctx.metadata
.get("user_id")
.cloned()
.unwrap_or_else(|| "anonymous".to_string())
}
}
impl Aspect for RateLimitAspect {
fn name(&self) -> &'static str {
"rate_limit"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let key = Self::caller_key(ctx);
let mut state = self.state.lock().expect("rate limit lock poisoned");
let now = Instant::now();
let window = state.entry(key).or_insert(RateWindow {
count: 0,
window_start: now,
window_duration: self.window,
});
// Reset window if expired
if now.duration_since(window.window_start) >= window.window_duration {
window.count = 0;
window.window_start = now;
}
if window.count >= self.max_requests {
return Err(AopError::RateLimited {
requests: self.max_requests,
per: self.window.as_secs(),
});
}
window.count += 1;
Ok(())
}
}
// ── @log ──────────────────────────────────────────────────────────────────────
/// `@log(level: "info")` — Structured logging for every method call.
pub struct LogAspect {
pub level: String,
}
impl LogAspect {
pub fn new(level: impl Into<String>) -> Self {
Self { level: level.into() }
}
}
impl Aspect for LogAspect {
fn name(&self) -> &'static str {
"log"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
// In production: use the `tracing` crate with the appropriate level macro.
let _log_entry = format!(
"[{}] {}.{}({:?})",
self.level.to_uppercase(),
ctx.target,
ctx.method,
ctx.args
);
let result = proceed(ctx.clone());
let _log_result = match &result {
Ok(r) => format!("[{}] {}.{} → ok: {}", self.level.to_uppercase(), ctx.target, ctx.method, r.value),
Err(e) => format!("[ERROR] {}.{} → err: {}", ctx.target, ctx.method, e),
};
result
}
}
// ── @validate ─────────────────────────────────────────────────────────────────
/// `@validate` — Run input validation before the method executes.
///
/// Validation rules are registered per method. If no rules are registered,
/// the aspect passes through (fail-open for ease of adoption).
pub struct ValidateAspect {
/// `"target::method"` → list of validation rules (field, rule_name)
rules: Mutex<HashMap<String, Vec<(String, String)>>>,
}
impl ValidateAspect {
pub fn new() -> Self {
Self {
rules: Mutex::new(HashMap::new()),
}
}
/// Add a validation rule. `rule` is one of: "required", "email", "min:N", "max:N".
pub fn add_rule(
&self,
target: &str,
method: &str,
field: impl Into<String>,
rule: impl Into<String>,
) {
let key = format!("{}::{}", target, method);
self.rules
.lock()
.expect("validate lock poisoned")
.entry(key)
.or_default()
.push((field.into(), rule.into()));
}
fn validate_field(value: &str, rule: &str) -> AopResult<()> {
if rule == "required" && value.trim().is_empty() {
return Err(AopError::ValidationFailed("field is required".into()));
}
if rule == "email" && !value.contains('@') {
return Err(AopError::ValidationFailed(format!(
"'{}' is not a valid email",
value
)));
}
if let Some(min_str) = rule.strip_prefix("min:") {
let min: usize = min_str.parse().unwrap_or(0);
if value.len() < min {
return Err(AopError::ValidationFailed(format!(
"minimum length is {}",
min
)));
}
}
if let Some(max_str) = rule.strip_prefix("max:") {
let max: usize = max_str.parse().unwrap_or(usize::MAX);
if value.len() > max {
return Err(AopError::ValidationFailed(format!(
"maximum length is {}",
max
)));
}
}
Ok(())
}
}
impl Default for ValidateAspect {
fn default() -> Self {
Self::new()
}
}
impl Aspect for ValidateAspect {
fn name(&self) -> &'static str {
"validate"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let key = format!("{}::{}", ctx.target, ctx.method);
let rules = self.rules.lock().expect("validate lock poisoned");
if let Some(field_rules) = rules.get(&key) {
for (field, rule) in field_rules {
let value = ctx.args.get(field).map(|s| s.as_str()).unwrap_or("");
Self::validate_field(value, rule)?;
}
}
Ok(())
}
}
// ── @retry ────────────────────────────────────────────────────────────────────
/// `@retry(attempts: 3, backoff: "exponential")` — Retry on failure.
pub struct RetryAspect {
pub attempts: u32,
pub backoff: BackoffStrategy,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BackoffStrategy {
None,
Fixed(Duration),
Exponential { base: Duration },
}
impl RetryAspect {
pub fn new(attempts: u32) -> Self {
Self { attempts, backoff: BackoffStrategy::None }
}
pub fn with_exponential_backoff(mut self, base_ms: u64) -> Self {
self.backoff = BackoffStrategy::Exponential {
base: Duration::from_millis(base_ms),
};
self
}
pub fn with_fixed_backoff(mut self, ms: u64) -> Self {
self.backoff = BackoffStrategy::Fixed(Duration::from_millis(ms));
self
}
fn sleep_duration(&self, attempt: u32) -> Duration {
match &self.backoff {
BackoffStrategy::None => Duration::ZERO,
BackoffStrategy::Fixed(d) => *d,
BackoffStrategy::Exponential { base } => {
// base * 2^attempt, capped at 30s
let factor = 1u64 << attempt.min(10);
std::cmp::min(*base * factor as u32, Duration::from_secs(30))
}
}
}
}
impl Aspect for RetryAspect {
fn name(&self) -> &'static str {
"retry"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
let mut last_error = String::new();
for attempt in 0..self.attempts {
match proceed(ctx.clone()) {
Ok(result) => return Ok(result),
Err(e) => {
last_error = e.to_string();
let sleep_for = self.sleep_duration(attempt);
if sleep_for > Duration::ZERO && attempt + 1 < self.attempts {
std::thread::sleep(sleep_for);
}
}
}
}
Err(AopError::RetriesExhausted {
attempts: self.attempts,
last_error,
})
}
}
// ── @trace ────────────────────────────────────────────────────────────────────
static TRACE_COUNTER: AtomicU64 = AtomicU64::new(1);
/// `@trace` — Add a distributed tracing span to every method call.
///
/// Injects a `trace_id` and `span_id` into context metadata.
/// In production, emit the span to an OpenTelemetry collector.
pub struct TraceAspect {
pub service_name: String,
}
impl TraceAspect {
pub fn new(service_name: impl Into<String>) -> Self {
Self { service_name: service_name.into() }
}
fn new_span_id() -> String {
let id = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("span-{:016x}", id)
}
}
impl Aspect for TraceAspect {
fn name(&self) -> &'static str {
"trace"
}
fn around(
&self,
mut ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
// Create or inherit trace ID
let trace_id = ctx
.metadata
.get("trace_id")
.cloned()
.unwrap_or_else(|| format!("trace-{:016x}", TRACE_COUNTER.load(Ordering::Relaxed)));
let span_id = Self::new_span_id();
ctx.metadata.insert("trace_id".into(), trace_id.clone());
ctx.metadata.insert("span_id".into(), span_id.clone());
let start = Instant::now();
let result = proceed(ctx.clone());
let duration_us = start.elapsed().as_micros();
// In production: emit span to OpenTelemetry:
// tracer.start_with_context("method_call", parent_cx)
// .set_attribute(KeyValue::new("service", self.service_name.clone()))
// .set_attribute(KeyValue::new("method", ctx.method.clone()))
// .set_attribute(KeyValue::new("duration_us", duration_us as i64))
// .end();
let _ = duration_us;
result.map(|mut r| {
r.metadata.insert("trace_id".into(), trace_id);
r.metadata.insert("span_id".into(), span_id);
r
})
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Aspect chain — ordered execution of aspects around a method call.
//!
//! Aspects execute in order: each one wraps the next, forming a chain.
//! The innermost item is the actual method invocation.
//!
//! ```text
//! @authenticate → @authorize → @cache → @log → [method body]
//! before before check log
//! hit? ──→ return cached
//! miss? → [method body] → store → after-log
//! ```
//!
//! ## Security-by-default
//!
//! `AspectChain::with_default_auth()` prepends `AuthenticateAspect` to every
//! chain. Call this when building chains for non-`@public` functions.
use crate::{aspects::AuthenticateAspect, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
use std::sync::Arc;
/// An ordered chain of aspects applied to a single method.
pub struct AspectChain {
aspects: Vec<Arc<dyn Aspect>>,
}
impl AspectChain {
pub fn new() -> Self {
Self { aspects: Vec::new() }
}
/// Add an aspect to the end of the chain.
pub fn add(mut self, aspect: Arc<dyn Aspect>) -> Self {
self.aspects.push(aspect);
self
}
/// Number of aspects in this chain.
pub fn len(&self) -> usize {
self.aspects.len()
}
pub fn is_empty(&self) -> bool {
self.aspects.is_empty()
}
/// Execute the chain around the given proceed function.
///
/// Aspects run in order (left to right in the decorator list).
/// Each aspect's `around` method receives the next aspect's `around`
/// as the `proceed` function, forming a true onion model.
pub fn execute(
&self,
ctx: InvocationContext,
proceed: ProceedFn,
) -> AopResult<InvocationResult> {
if self.aspects.is_empty() {
return proceed(ctx);
}
self.run_aspect(0, ctx, proceed)
}
fn run_aspect(
&self,
index: usize,
ctx: InvocationContext,
final_proceed: ProceedFn,
) -> AopResult<InvocationResult> {
if index >= self.aspects.len() {
return final_proceed(ctx);
}
let aspect = self.aspects[index].clone();
let remaining_aspects = self.aspects[index + 1..].to_vec();
let final_proceed = Arc::new(final_proceed);
let next: ProceedFn = Box::new(move |ctx: InvocationContext| {
if remaining_aspects.is_empty() {
return final_proceed(ctx);
}
// Build remaining chain recursively
let sub_chain = AspectChain {
aspects: remaining_aspects.clone(),
};
sub_chain.execute(ctx, {
let fp = final_proceed.clone();
Box::new(move |ctx| fp(ctx))
})
});
aspect.around(ctx, &next)
}
/// Return the names of all aspects in this chain (in order).
pub fn aspect_names(&self) -> Vec<&str> {
self.aspects.iter().map(|a| a.name()).collect()
}
/// Prepend `AuthenticateAspect` to this chain.
///
/// This is the mechanism for security-by-default: the framework calls
/// `with_default_auth()` on every chain that does NOT have `@public`.
///
/// Equivalent to `.add(Arc::new(AuthenticateAspect))` at position 0, but
/// semantically explicit about what it means.
pub fn with_default_auth(self) -> Self {
let mut aspects = vec![Arc::new(AuthenticateAspect) as Arc<dyn Aspect>];
aspects.extend(self.aspects);
Self { aspects }
}
/// Returns `true` if the chain contains an `AuthenticateAspect`.
pub fn has_auth(&self) -> bool {
self.aspects.iter().any(|a| a.name() == "authenticate")
}
}
impl Default for AspectChain {
fn default() -> Self {
Self::new()
}
}
+157
View File
@@ -0,0 +1,157 @@
//! el-aop — Aspect-Oriented Programming for el-ui.
//!
//! Cross-cutting concerns as first-class language features. Not a library you
//! import. Built into the framework. Applied as decorators:
//!
//! ```text
//! @authenticate ← applied by DEFAULT to every function
//! @authorize(role: "admin")
//! @cache(ttl: 300)
//! @rate_limit(requests: 100, per: 60)
//! component AdminDashboard { ... }
//!
//! @public ← explicit opt-out of authentication
//! component LandingPage { ... }
//! ```
//!
//! ## Security-by-default
//!
//! `@authenticate` is the default. Functions without `@public` are protected.
//! This makes it as hard as possible to accidentally ship an unprotected endpoint.
pub mod aspects;
pub mod chain;
pub mod registry;
pub use aspects::{
AuthenticateAspect, AuthorizeAspect, CacheAspect, LogAspect, RateLimitAspect, RetryAspect,
TraceAspect, ValidateAspect,
};
pub use chain::AspectChain;
pub use public::PublicMarker;
pub use registry::AspectRegistry;
pub mod public;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AopError {
#[error("authentication required")]
Unauthenticated,
#[error("forbidden: requires role '{role}'")]
Forbidden { role: String },
#[error("rate limit exceeded: {requests} requests per {per}s")]
RateLimited { requests: u32, per: u64 },
#[error("validation failed: {0}")]
ValidationFailed(String),
#[error("aspect error: {0}")]
Aspect(String),
#[error("all {attempts} retry attempts failed: {last_error}")]
RetriesExhausted { attempts: u32, last_error: String },
}
pub type AopResult<T> = Result<T, AopError>;
/// Context passed through the aspect chain.
///
/// Contains the incoming arguments and metadata about the call.
/// Aspects can read and mutate this context as they execute.
#[derive(Debug, Clone)]
pub struct InvocationContext {
/// The component or service being called.
pub target: String,
/// The method being called.
pub method: String,
/// Arguments passed to the method.
pub args: HashMap<String, String>,
/// Metadata added by aspects (e.g., the authenticated user, trace ID).
pub metadata: HashMap<String, String>,
}
impl InvocationContext {
pub fn new(target: impl Into<String>, method: impl Into<String>) -> Self {
Self {
target: target.into(),
method: method.into(),
args: HashMap::new(),
metadata: HashMap::new(),
}
}
pub fn with_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.args.insert(key.into(), value.into());
self
}
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn get_meta(&self, key: &str) -> Option<&str> {
self.metadata.get(key).map(|s| s.as_str())
}
}
/// The result of invoking a method through an aspect chain.
#[derive(Debug, Clone)]
pub struct InvocationResult {
pub value: String,
pub metadata: HashMap<String, String>,
}
impl InvocationResult {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
metadata: HashMap::new(),
}
}
}
/// A handler that performs the actual method invocation.
/// Aspects wrap around this.
pub type ProceedFn = Box<dyn Fn(InvocationContext) -> AopResult<InvocationResult> + Send + Sync>;
/// The core Aspect trait.
///
/// Each aspect implements `before`, `after`, or `around` advice.
/// The default implementations are no-ops — only override what you need.
pub trait Aspect: Send + Sync {
/// The aspect's name (used for debugging and registry lookup).
fn name(&self) -> &'static str;
/// Before advice — runs before the method. Can reject the call.
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let _ = ctx;
Ok(())
}
/// After advice — runs after the method. Receives the result.
/// Can modify the result or perform cleanup.
fn after(
&self,
ctx: &InvocationContext,
result: AopResult<InvocationResult>,
) -> AopResult<InvocationResult> {
let _ = ctx;
result
}
/// Around advice — wraps the entire invocation.
/// The default implementation calls `before`, then `proceed`, then `after`.
fn around(
&self,
mut ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
self.before(&mut ctx)?;
let result = proceed(ctx.clone());
self.after(&ctx, result)
}
}
+223
View File
@@ -0,0 +1,223 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Each aspect has three advice points: before, after, around. The aspect
// chain is composed at compile time by the el-ui-compiler from decorators.
//
// The El runtime model uses tagged JSON for InvocationContext so an aspect
// chain composes purely by passing the context map through each advice fn.
//
// Built-in aspects:
// @authenticate defaults on every component (security-by-default)
// @public opt-out marker
// @authorize role/permission gate
// @cache TTL-keyed memoization
// @rate_limit per-principal token bucket
// @retry retry on error with backoff
// @log structured log around invocation
// @trace emit OpenTelemetry-shaped spans
// @validate JSON schema check on args
// Errors
let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated"
let AOP_ERR_FORBIDDEN: String = "aop.forbidden"
let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited"
let AOP_ERR_VALIDATION: String = "aop.validation_failed"
let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted"
// Invocation context
//
// Stored as JSON: { target, method, args:{}, metadata:{} }
// All advice mutates by returning a new ctx (functional style).
fn ctx_new(target: String, method: String) -> String {
"{\"target\":\"" + target + "\",\"method\":\"" + method
+ "\",\"args\":{},\"metadata\":{}}"
}
fn ctx_with_arg(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "args." + key, "\"" + value + "\"")
}
fn ctx_with_meta(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "metadata." + key, "\"" + value + "\"")
}
fn ctx_get_meta(ctx: String, key: String) -> String {
json_get_path(ctx, "metadata." + key)
}
// Aspect dispatch table
//
// Each aspect is a triple of fn names: (before, around, after). The registry
// is a map from aspect name -> { before, around, after } JSON.
fn registry_new() -> String {
"{}"
}
fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String {
let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn
+ "\",\"after\":\"" + after_fn + "\"}"
json_set(reg, name, entry)
}
// @authenticate applied by default
fn aspect_authenticate_before(ctx: String) -> String {
let token: String = ctx_get_meta(ctx, "authorization")
if str_eq(token, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let secret: String = env("JWT_SECRET")
let auth_ctx: String = auth_middleware(token, secret)
let user_id: String = json_get(auth_ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
ctx_with_meta(ctx, "user_id", user_id)
}
// @public explicit opt-out marker
fn aspect_public_before(ctx: String) -> String {
ctx_with_meta(ctx, "public", "true")
}
// @authorize(role)
fn aspect_authorize_before(ctx: String, required_role: String) -> String {
let user_id: String = ctx_get_meta(ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let roles_json: String = engram_edge_traverse(user_id, "has_role")
if !str_contains(roles_json, "\"" + required_role + "\"") {
return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN)
}
ctx
}
// @cache(ttl_seconds)
fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if str_eq(key, "") {
let target: String = json_get(ctx, "target")
let method: String = json_get(ctx, "method")
let args: String = json_get(ctx, "args")
let key = sha256_hex(target + ":" + method + ":" + args)
}
let cached: String = cache_get(key)
if !str_eq(cached, "") {
return ctx_with_meta(ctx, "result", cached)
}
// Caller invokes proceed(ctx) externally; we record the key for `after` to use.
ctx_with_meta(ctx, "cache_key", key)
}
fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if !str_eq(key, "") { cache_put(key, result, ttl) }
result
}
// @rate_limit(requests, per_seconds)
fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String {
let principal: String = ctx_get_meta(ctx, "user_id")
if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") }
let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal
let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds)
if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) }
ctx
}
// @retry(attempts, backoff_ms)
//
// retry is necessarily an `around` aspect it must own the loop.
fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String {
let i: Int = 0
let result: String = ""
while i < attempts {
let result = call_dynamic(proceed_fn_name, ctx)
let err: String = json_get(result, "error")
if str_eq(err, "") { return result }
sleep_ms(backoff_ms * (i + 1))
let i = i + 1
}
ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED)
}
// @log / @trace
fn aspect_log_before(ctx: String) -> String {
println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
ctx
}
fn aspect_log_after(ctx: String, result: String) -> String {
println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
result
}
fn aspect_trace_before(ctx: String) -> String {
let span_id: String = uuid_v4()
ctx_with_meta(ctx, "span_id", span_id)
}
// @validate(schema)
fn aspect_validate_before(ctx: String, schema_json: String) -> String {
let args: String = json_get(ctx, "args")
let valid: Bool = json_schema_check(args, schema_json)
if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) }
ctx
}
// Aspect chain composition
//
// The compiler emits a call sequence like:
// ctx = ctx_new(...)
// ctx = aspect_authenticate_before(ctx)
// ctx = aspect_log_before(ctx)
// result = proceed(ctx)
// result = aspect_log_after(ctx, result)
// At runtime an explicit `chain_run` exists for dynamic composition.
fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String {
let cur_ctx: String = ctx
// before chain
let i: Int = 0
let befs: String = before_fns
while !str_eq(befs, "") {
let comma: Int = str_index_of(befs, ",")
let fn_name: String = befs
if comma > 0 { let fn_name = str_slice(befs, 0, comma) }
let cur_ctx = call_dynamic(fn_name, cur_ctx)
let err: String = ctx_get_meta(cur_ctx, "error")
if !str_eq(err, "") { return cur_ctx }
if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) }
if comma < 0 { let befs = "" }
}
// around / proceed
let result: String = call_dynamic(proceed_fn, cur_ctx)
// after chain (right-to-left composition; simplified left-to-right here)
let afts: String = after_fns
while !str_eq(afts, "") {
let comma: Int = str_index_of(afts, ",")
let fn_name: String = afts
if comma > 0 { let fn_name = str_slice(afts, 0, comma) }
let result = call_dynamic2(fn_name, cur_ctx, result)
if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) }
if comma < 0 { let afts = "" }
}
result
}
// Entry smoke test
let ctx: String = ctx_new("ProfilePage", "render")
let ctx = ctx_with_arg(ctx, "user_id", "u-001")
println("[el-aop] ctx = " + ctx)
+57
View File
@@ -0,0 +1,57 @@
//! `@public` marker — the explicit opt-out of authentication.
//!
//! The security-by-default model: `@authenticate` is applied to EVERY function
//! by default. `@public` is the rare annotation that says "this endpoint
//! intentionally has no auth".
//!
//! `PublicMarker` is a zero-cost marker. When the compiler sees `@public` on a
//! function, it strips `AuthenticateAspect` from the chain for that function.
//! The chain-builder checks `is_public` before prepending default auth.
/// Zero-cost marker indicating that a function is intentionally public.
///
/// When `@public` is present, the default `AuthenticateAspect` is NOT added
/// to the function's aspect chain.
///
/// Usage in the el-ui compiler:
/// ```text
/// @public
/// fn health_check() -> Status { ... }
/// ```
///
/// In the AOP chain builder:
/// ```rust
/// use el_aop::{AspectChain, PublicMarker, AuthenticateAspect};
/// use std::sync::Arc;
///
/// fn build_chain(is_public: bool) -> AspectChain {
/// if is_public || PublicMarker::is_bypassing() {
/// AspectChain::new()
/// } else {
/// AspectChain::new().with_default_auth()
/// }
/// }
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PublicMarker;
impl PublicMarker {
/// Returns `true` — always. Exists for use in match arms / conditional logic.
///
/// The presence of a `PublicMarker` in the decorator list is the signal;
/// this method is a convenience for procedural logic over decorator lists.
pub const fn is_bypassing() -> bool {
true
}
/// The decorator name this marker corresponds to.
pub const fn decorator_name() -> &'static str {
"public"
}
}
impl std::fmt::Display for PublicMarker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "@public")
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Aspect registry — register built-in and custom aspects by name.
//!
//! The compiler's AOP codegen uses the registry to look up aspect implementations
//! by their decorator name (e.g., `"authenticate"` → `AuthenticateAspect`).
//!
//! ## Security-by-default
//!
//! `"public"` is a special bypass marker — NOT an aspect. When the compiler sees
//! `@public` it calls `registry.is_public_bypass(name)` and skips default auth.
//!
//! `set_default_auth_guard()` installs the global default `AuthenticateAspect`
//! that is prepended to every non-`@public` chain.
use crate::Aspect;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type AspectFactory = Box<dyn Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync>;
/// Registry of aspect factories, indexed by decorator name.
pub struct AspectRegistry {
factories: RwLock<HashMap<String, AspectFactory>>,
/// When `true`, the registry is configured to prepend AuthenticateAspect
/// to every non-`@public` chain via `AspectChain::with_default_auth()`.
default_auth_enabled: RwLock<bool>,
/// Names that are bypass markers (not real aspects). Currently just "public".
bypass_markers: RwLock<std::collections::HashSet<String>>,
}
impl AspectRegistry {
pub fn new() -> Self {
let mut markers = std::collections::HashSet::new();
markers.insert("public".to_string());
Self {
factories: RwLock::new(HashMap::new()),
default_auth_enabled: RwLock::new(false),
bypass_markers: RwLock::new(markers),
}
}
/// Create a registry with all built-in aspects registered.
pub fn with_builtins() -> Self {
let registry = Self::new();
registry.register_builtins();
registry
}
/// Enable security-by-default: every non-`@public` chain will have
/// `AuthenticateAspect` prepended automatically.
///
/// Call at application startup. After this, use `AspectChain::with_default_auth()`
/// when building chains for protected functions.
pub fn set_default_auth_enabled(&self, enabled: bool) {
*self.default_auth_enabled.write().expect("registry lock poisoned") = enabled;
}
/// Returns `true` if security-by-default auth is active.
pub fn is_default_auth_enabled(&self) -> bool {
*self.default_auth_enabled.read().expect("registry lock poisoned")
}
/// Returns `true` if `name` is a public bypass marker (e.g., `"public"`).
///
/// Bypass markers are NOT aspects — they signal that default auth should
/// be skipped for the decorated function.
pub fn is_public_bypass(&self, name: &str) -> bool {
self.bypass_markers
.read()
.expect("registry lock poisoned")
.contains(name)
}
/// Register a custom bypass marker name.
///
/// By default only `"public"` is registered. Use this to add custom
/// bypass annotations (e.g., `"internal_only"` that uses a different guard).
pub fn register_bypass_marker(&self, name: &str) {
self.bypass_markers
.write()
.expect("registry lock poisoned")
.insert(name.to_string());
}
/// Build an `AspectChain` for a function with the given decorators.
///
/// This is the primary chain-building entry point used by the AOP codegen.
///
/// - If any decorator is a bypass marker (`@public`), returns a plain chain
/// with no default auth.
/// - Otherwise, if `default_auth_enabled`, prepends `AuthenticateAspect`.
/// - Unknown decorator names are silently skipped (forward-compatible).
pub fn build_chain(&self, decorator_names: &[(&str, HashMap<String, String>)]) -> crate::AspectChain {
let is_public = decorator_names.iter().any(|(name, _)| self.is_public_bypass(name));
let mut chain = crate::AspectChain::new();
for (name, params) in decorator_names {
if self.is_public_bypass(name) {
continue; // bypass markers are not aspects
}
if let Some(aspect) = self.create(name, params) {
chain = chain.add(aspect);
}
}
if !is_public && self.is_default_auth_enabled() {
chain = chain.with_default_auth();
}
chain
}
/// Register all built-in aspects.
pub fn register_builtins(&self) {
use crate::aspects::*;
self.register("authenticate", |_params| {
Arc::new(AuthenticateAspect)
});
self.register("authorize", |params| {
let role = params
.get("role")
.cloned()
.unwrap_or_else(|| "user".to_string());
Arc::new(AuthorizeAspect::new(role))
});
self.register("cache", |params| {
let ttl: u64 = params
.get("ttl")
.and_then(|s| s.parse().ok())
.unwrap_or(300);
Arc::new(CacheAspect::new(ttl))
});
self.register("rate_limit", |params| {
let requests: u32 = params
.get("requests")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let per: u64 = params
.get("per")
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Arc::new(RateLimitAspect::new(requests, per))
});
self.register("log", |params| {
let level = params
.get("level")
.cloned()
.unwrap_or_else(|| "info".to_string());
Arc::new(LogAspect::new(level))
});
self.register("validate", |_params| Arc::new(ValidateAspect::new()));
self.register("retry", |params| {
let attempts: u32 = params
.get("attempts")
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let backoff = params
.get("backoff")
.map(|s| s.as_str())
.unwrap_or("none");
let aspect = RetryAspect::new(attempts);
let aspect = match backoff {
"exponential" => aspect.with_exponential_backoff(100),
"fixed" => aspect.with_fixed_backoff(500),
_ => aspect,
};
Arc::new(aspect)
});
self.register("trace", |params| {
let service = params
.get("service")
.cloned()
.unwrap_or_else(|| "el-ui".to_string());
Arc::new(TraceAspect::new(service))
});
}
/// Register a custom aspect factory.
pub fn register(
&self,
name: &str,
factory: impl Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync + 'static,
) {
self.factories
.write()
.expect("registry lock poisoned")
.insert(name.to_string(), Box::new(factory));
}
/// Instantiate an aspect by decorator name with the given params.
pub fn create(
&self,
name: &str,
params: &HashMap<String, String>,
) -> Option<Arc<dyn Aspect>> {
let factories = self.factories.read().expect("registry lock poisoned");
factories.get(name).map(|f| f(params))
}
/// List all registered aspect names.
pub fn aspect_names(&self) -> Vec<String> {
self.factories
.read()
.expect("registry lock poisoned")
.keys()
.cloned()
.collect()
}
/// Check if an aspect name is registered.
pub fn contains(&self, name: &str) -> bool {
self.factories
.read()
.expect("registry lock poisoned")
.contains_key(name)
}
}
impl Default for AspectRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod registry_default_auth_tests {
use super::*;
#[test]
fn test_default_auth_disabled_by_default() {
let reg = AspectRegistry::new();
assert!(!reg.is_default_auth_enabled());
}
#[test]
fn test_set_default_auth_enabled() {
let reg = AspectRegistry::new();
reg.set_default_auth_enabled(true);
assert!(reg.is_default_auth_enabled());
}
#[test]
fn test_public_is_bypass_marker() {
let reg = AspectRegistry::new();
assert!(reg.is_public_bypass("public"));
assert!(!reg.is_public_bypass("authenticate"));
}
#[test]
fn test_build_chain_public_skips_default_auth() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("public", HashMap::new())];
let chain = reg.build_chain(&decorators);
assert!(!chain.has_auth(), "public chain should not have default auth");
}
#[test]
fn test_build_chain_non_public_gets_default_auth() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("log", HashMap::new())];
let chain = reg.build_chain(&decorators);
assert!(chain.has_auth(), "non-public chain should have default auth prepended");
}
#[test]
fn test_build_chain_auth_is_first_aspect() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("log", HashMap::new())];
let chain = reg.build_chain(&decorators);
let names = chain.aspect_names();
assert_eq!(names[0], "authenticate", "authenticate must be first in the chain");
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Tests for el-aop.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use crate::{
aspects::*,
chain::AspectChain,
registry::AspectRegistry,
AopError, Aspect, InvocationContext, InvocationResult,
};
fn succeed_proceed(value: impl Into<String> + Clone) -> crate::ProceedFn {
let v = value.into();
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
}
fn fail_proceed(msg: impl Into<String> + Clone) -> crate::ProceedFn {
let m = msg.into();
Box::new(move |_ctx| Err(AopError::Aspect(m.clone())))
}
fn ctx(target: &str, method: &str) -> InvocationContext {
InvocationContext::new(target, method)
}
fn authed_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method).with_meta("user_id", "user-123")
}
fn admin_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method)
.with_meta("user_id", "admin-1")
.with_meta("roles", "admin,user")
}
// ── Test 1: AuthenticateAspect rejects unauthenticated calls ─────────────
#[test]
fn test_authenticate_rejects_unauthenticated() {
let aspect = AuthenticateAspect;
let mut ctx = ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_err());
assert!(matches!(result, Err(AopError::Unauthenticated)));
}
// ── Test 2: AuthenticateAspect allows authenticated calls ─────────────────
#[test]
fn test_authenticate_allows_authenticated() {
let aspect = AuthenticateAspect;
let mut ctx = authed_ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 3: AuthorizeAspect rejects wrong role ────────────────────────────
#[test]
fn test_authorize_rejects_wrong_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::Forbidden { .. })));
}
// ── Test 4: AuthorizeAspect allows correct role ───────────────────────────
#[test]
fn test_authorize_allows_correct_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = admin_ctx("Dashboard", "delete");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 5: CacheAspect returns cached result on second call ──────────────
#[test]
fn test_cache_returns_cached_result() {
let aspect = CacheAspect::new(300);
let ctx = authed_ctx("OrderService", "get_orders");
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let cc = call_count.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
Ok(InvocationResult::new(format!("result-{}", n)))
});
// First call — executes proceed
let r1 = aspect.around(ctx.clone(), &proceed).unwrap();
// Second call — should return cached (proceed not called again)
let proceed2: crate::ProceedFn = Box::new(|_ctx| {
panic!("proceed should not be called on cache hit");
});
let r2 = aspect.around(ctx.clone(), &proceed2).unwrap();
assert_eq!(r1.value, r2.value, "cached value should be returned");
}
// ── Test 6: RateLimitAspect blocks after limit exceeded ───────────────────
#[test]
fn test_rate_limit_blocks_after_limit() {
let aspect = RateLimitAspect::new(2, 60);
let mut ctx = authed_ctx("OrderService", "create_order");
// First two calls succeed
assert!(aspect.before(&mut ctx).is_ok());
assert!(aspect.before(&mut ctx).is_ok());
// Third call should be blocked
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::RateLimited { .. })));
}
// ── Test 7: LogAspect passes through to proceed ───────────────────────────
#[test]
fn test_log_aspect_passthrough() {
let aspect = LogAspect::new("info");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap();
assert_eq!(result.value, "user-data");
}
// ── Test 8: ValidateAspect rejects required field missing ────────────────
#[test]
fn test_validate_required_field() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "name", "required");
let mut ctx = authed_ctx("UserService", "create_user");
// No "name" arg
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 9: ValidateAspect passes when field is present ──────────────────
#[test]
fn test_validate_required_field_present() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "alice@example.com");
assert!(aspect.before(&mut ctx).is_ok());
}
// ── Test 10: ValidateAspect rejects invalid email ─────────────────────────
#[test]
fn test_validate_email_rule() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "not-an-email");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 11: RetryAspect retries on failure ───────────────────────────────
#[test]
fn test_retry_succeeds_on_third_attempt() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let attempt_clone = attempt.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n < 2 {
Err(AopError::Aspect("transient error".into()))
} else {
Ok(InvocationResult::new("success"))
}
});
let result = aspect.around(ctx, &proceed).unwrap();
assert_eq!(result.value, "success");
}
// ── Test 12: RetryAspect exhausts all attempts ───────────────────────────
#[test]
fn test_retry_exhausted() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let result = aspect.around(ctx, &fail_proceed("always fails"));
assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. })));
}
// ── Test 13: TraceAspect injects trace/span IDs ───────────────────────────
#[test]
fn test_trace_aspect_injects_ids() {
let aspect = TraceAspect::new("el-ui");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("data")).unwrap();
assert!(
result.metadata.contains_key("trace_id"),
"should inject trace_id"
);
assert!(
result.metadata.contains_key("span_id"),
"should inject span_id"
);
}
// ── Test 14: AspectChain executes aspects in order ────────────────────────
#[test]
fn test_aspect_chain_ordering() {
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
struct OrderTracker {
name: &'static str,
order: Arc<std::sync::Mutex<Vec<&'static str>>>,
}
impl Aspect for OrderTracker {
fn name(&self) -> &'static str { self.name }
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
self.order.lock().unwrap().push(self.name);
proceed(ctx)
}
}
let chain = AspectChain::new()
.add(Arc::new(OrderTracker { name: "first", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "second", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "third", order: order.clone() }));
let ctx = authed_ctx("MyService", "my_method");
chain.execute(ctx, succeed_proceed("ok")).unwrap();
let recorded = order.lock().unwrap();
assert_eq!(*recorded, vec!["first", "second", "third"]);
}
// ── Test 15: AspectChain with auth + authorize rejects unauthenticated ────
#[test]
fn test_aspect_chain_auth_flow() {
let chain = AspectChain::new()
.add(Arc::new(AuthenticateAspect))
.add(Arc::new(AuthorizeAspect::new("admin")));
// Unauthenticated — should fail at authenticate
let ctx = ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Unauthenticated)));
// Authenticated but wrong role — should fail at authorize
let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Forbidden { .. })));
// Admin — should succeed
let ctx = admin_ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(result.is_ok());
}
// ── Test 16: AspectRegistry registers all builtins ────────────────────────
#[test]
fn test_registry_has_builtins() {
let registry = AspectRegistry::with_builtins();
for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] {
assert!(registry.contains(name), "should have built-in: {}", name);
}
}
// ── Test 17: AspectRegistry creates aspects from params ───────────────────
#[test]
fn test_registry_creates_aspect() {
let registry = AspectRegistry::with_builtins();
let mut params = HashMap::new();
params.insert("role".into(), "admin".into());
let aspect = registry.create("authorize", &params);
assert!(aspect.is_some(), "should create authorize aspect");
assert_eq!(aspect.unwrap().name(), "authorize");
}
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
#[test]
fn test_registry_unknown_aspect() {
let registry = AspectRegistry::with_builtins();
let result = registry.create("unknown_aspect", &HashMap::new());
assert!(result.is_none());
}
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
#[test]
fn test_cache_zero_ttl() {
let aspect = CacheAspect::new(0); // Immediate expiry
let ctx = authed_ctx("Service", "method");
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
let nc = n.clone();
// Both calls should hit proceed since ttl=0 means instant expiry
let p1: crate::ProceedFn = Box::new(move |_ctx| {
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(InvocationResult::new(v.to_string()))
});
// Zero TTL will expire immediately; we just verify it doesn't panic
let _ = aspect.around(ctx.clone(), &p1);
// Second call should also execute proceed
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
let r = aspect.around(ctx, &p2).unwrap();
assert_eq!(r.value, "fresh");
}
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
#[test]
fn test_empty_chain_calls_proceed() {
let chain = AspectChain::new();
assert!(chain.is_empty());
let ctx = authed_ctx("Service", "method");
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
assert_eq!(result.value, "direct");
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "el-auth"
version = "0.1.0"
edition = "2021"
description = "el-ui built-in authentication and authorization — native to the framework"
license = "MIT"
[lib]
name = "el_auth"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
el-identity = { path = "../el-identity" }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
+15
View File
@@ -0,0 +1,15 @@
vessel "el-auth" {
version "0.1.0"
description "Authentication and authorization: JWT, sessions, roles, permissions"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+78
View File
@@ -0,0 +1,78 @@
//! Auth context — the current authenticated user and their roles/permissions.
/// The authenticated user.
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: String,
pub email: String,
pub name: String,
}
impl AuthUser {
pub fn new(
id: impl Into<String>,
email: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
id: id.into(),
email: email.into(),
name: name.into(),
}
}
}
/// The auth context — populated by `AuthMiddleware` and available to all
/// components and services downstream in the request.
///
/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP
/// layer (see `el-aop`).
#[derive(Debug, Clone)]
pub struct AuthContext {
pub user: Option<AuthUser>,
pub roles: Vec<String>,
pub permissions: Vec<String>,
/// The raw token/session ID that was verified.
pub token: String,
}
impl AuthContext {
pub fn anonymous() -> Self {
Self {
user: None,
roles: Vec::new(),
permissions: Vec::new(),
token: String::new(),
}
}
pub fn authenticated(user: AuthUser, roles: Vec<String>, token: impl Into<String>) -> Self {
Self {
user: Some(user),
roles,
permissions: Vec::new(),
token: token.into(),
}
}
pub fn with_permissions(mut self, perms: Vec<String>) -> Self {
self.permissions = perms;
self
}
pub fn is_authenticated(&self) -> bool {
self.user.is_some()
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions.iter().any(|p| p == permission)
}
pub fn user_id(&self) -> Option<&str> {
self.user.as_ref().map(|u| u.id.as_str())
}
}
+128
View File
@@ -0,0 +1,128 @@
//! EngramSessionStore — a `SessionProvider`-compatible store backed by the Engram graph.
//!
//! Sessions are Engram graph nodes (via el-identity's `SessionManager`).
//! This allows server-side invalidation even for stateless JWT workflows:
//! on every request, the JWT's `session_id` claim is used to look up the
//! Session node in Engram. If the node is missing or expired, the request
//! is rejected regardless of JWT validity.
//!
//! Dependency: takes `Arc<dyn EngramClient>` — soft dependency, no tight coupling
//! to the Engram crate.
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
use el_identity::{
engram::EngramClient,
session::SessionManager,
};
use std::sync::Arc;
/// Session provider backed by the Engram identity graph.
///
/// Sessions issued by this provider are stored as Engram Session nodes.
/// The session ID returned is the Engram node's UUID, which is also embedded
/// in JWTs via `JwtClaims::session_id`.
pub struct EngramSessionStore {
session_manager: Arc<SessionManager>,
client: Arc<dyn EngramClient>,
}
impl EngramSessionStore {
/// Create a new `EngramSessionStore`.
///
/// `client` is the Engram graph client. In production, pass your real
/// Engram client. In tests, use `el_identity::engram::MockEngramClient`.
pub fn new(client: Arc<dyn EngramClient>) -> Self {
let sm = Arc::new(SessionManager::new(client.clone()));
Self { session_manager: sm, client }
}
/// Create with a custom session TTL (seconds).
pub fn with_ttl(client: Arc<dyn EngramClient>, ttl_seconds: i64) -> Self {
let sm = Arc::new(SessionManager::new(client.clone()).with_ttl(ttl_seconds));
Self { session_manager: sm, client }
}
/// Expose the underlying SessionManager for advanced use (e.g., listing sessions).
pub fn session_manager(&self) -> &Arc<SessionManager> {
&self.session_manager
}
}
impl AuthProvider for EngramSessionStore {
fn name(&self) -> &'static str {
"engram_session"
}
/// Verify a session by its ID (Engram Session node UUID).
///
/// Validates expiry via graph lookup. Returns `AuthContext` populated from
/// the Session and User nodes.
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
// Validate session node (checks expiry, lazy-deletes expired)
let session = self
.session_manager
.validate(session_id)
.map_err(|e| match e {
el_identity::IdentityError::SessionNotFound => AuthError::SessionNotFound,
el_identity::IdentityError::SessionExpired => AuthError::SessionNotFound,
other => AuthError::Config(other.to_string()),
})?;
// Load user node
let user_id_str = session.user_id.to_string();
let user_node = self
.client
.get_node(&user_id_str)
.map_err(|e| AuthError::Config(e.to_string()))?
.ok_or(AuthError::InvalidCredentials)?;
let identity_user = el_identity::User::from_value(&user_node)
.ok_or_else(|| AuthError::Config("user node parse failed".into()))?;
let auth_user = AuthUser::new(
identity_user.id.to_string(),
&identity_user.email,
&identity_user.display_name,
);
// Load roles via has_role edges
let role_nodes = self
.client
.find_connected(&user_id_str, el_identity::nodes::EDGE_HAS_ROLE)
.map_err(|e| AuthError::Config(e.to_string()))?;
let role_names: Vec<String> = role_nodes
.iter()
.filter_map(el_identity::Role::from_value)
.map(|r| r.name)
.collect();
Ok(AuthContext::authenticated(auth_user, role_names, session_id))
}
/// Issue a new Engram-backed session for the given user.
///
/// The user must already exist as a User node in Engram. Returns the
/// session ID (UUID string) which should be embedded in the JWT's
/// `session_id` claim.
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
// Parse user ID as UUID
let user_uuid = uuid::Uuid::parse_str(&user.id)
.map_err(|_| AuthError::Config(format!("invalid user ID UUID: {}", user.id)))?;
let session = self
.session_manager
.create(user_uuid, None)
.map_err(|e| AuthError::Config(e.to_string()))?;
Ok(session.id.to_string())
}
/// Revoke a session by deleting the Session node from the graph.
fn revoke(&self, session_id: &str) -> AuthResult<()> {
self.session_manager
.invalidate(session_id)
.map_err(|e| AuthError::Config(e.to_string()))
}
}
+314
View File
@@ -0,0 +1,314 @@
//! JWT provider — sign and verify JSON Web Tokens.
//!
//! Uses HMAC-SHA256 (HS256) for signing. Does NOT use the `jsonwebtoken` crate
//! to keep dependencies minimal; implements the JWT spec directly.
//!
//! Format: base64url(header).base64url(payload).base64url(signature)
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// JWT claims payload.
///
/// The `session_id` field is included so the Engram session node can be
/// validated on every request, enabling server-side session invalidation
/// even for stateless JWTs.
#[derive(Debug, Clone)]
pub struct JwtClaims {
pub sub: String, // user ID
pub email: String,
pub name: String,
pub roles: Vec<String>,
/// The Engram Session node ID. Used by `EngramSessionStore` to validate
/// the session graph node on every request, enabling server-side logout.
pub session_id: Option<String>,
pub iat: u64, // issued-at (unix seconds)
pub exp: u64, // expiry (unix seconds)
}
impl JwtClaims {
pub fn new(user: &AuthUser, roles: Vec<String>, ttl_seconds: u64) -> Self {
let now = unix_now();
Self {
sub: user.id.clone(),
email: user.email.clone(),
name: user.name.clone(),
roles,
session_id: None,
iat: now,
exp: now + ttl_seconds,
}
}
/// Create claims with an Engram session ID embedded.
pub fn new_with_session(
user: &AuthUser,
roles: Vec<String>,
session_id: impl Into<String>,
ttl_seconds: u64,
) -> Self {
let mut claims = Self::new(user, roles, ttl_seconds);
claims.session_id = Some(session_id.into());
claims
}
pub fn is_expired(&self) -> bool {
unix_now() > self.exp
}
/// Serialize claims to JSON (manual, no serde dependency).
pub fn to_json(&self) -> String {
let roles_json = self
.roles
.iter()
.map(|r| format!("\"{}\"", r))
.collect::<Vec<_>>()
.join(",");
// Build the JSON manually, inserting session_id only when present.
let mut json = format!(
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}]",
self.sub, self.email, self.name, roles_json
);
if let Some(sid) = &self.session_id {
json.push_str(&format!(",\"session_id\":\"{}\"", sid));
}
json.push_str(&format!(",\"iat\":{},\"exp\":{}}}", self.iat, self.exp));
json
}
/// Deserialize claims from JSON (manual parser).
pub fn from_json(json: &str) -> Option<Self> {
let sub = extract_str(json, "sub")?;
let email = extract_str(json, "email").unwrap_or_default();
let name = extract_str(json, "name").unwrap_or_default();
let iat = extract_u64(json, "iat").unwrap_or(0);
let exp = extract_u64(json, "exp").unwrap_or(0);
let roles = extract_str_array(json, "roles");
let session_id = extract_str(json, "session_id");
Some(Self { sub, email, name, roles, session_id, iat, exp })
}
}
/// JWT provider — issues and verifies HS256 JWTs.
pub struct JwtProvider {
secret: Vec<u8>,
/// Token TTL in seconds (default: 3600 = 1 hour).
pub ttl_seconds: u64,
}
impl JwtProvider {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self { secret: secret.into(), ttl_seconds: 3600 }
}
pub fn from_env(env_var: &str) -> AuthResult<Self> {
let secret = std::env::var(env_var).map_err(|_| {
AuthError::Config(format!("env var {} not set", env_var))
})?;
Ok(Self::new(secret.into_bytes()))
}
pub fn with_ttl(mut self, seconds: u64) -> Self {
self.ttl_seconds = seconds;
self
}
/// Sign a token with HMAC-SHA256.
fn sign(&self, header_payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.secret)
.expect("HMAC can take key of any size");
mac.update(header_payload.as_bytes());
let result = mac.finalize();
base64url_encode(&result.into_bytes())
}
/// Encode a JWT token from claims.
pub fn encode(&self, claims: &JwtClaims) -> String {
let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
let payload = base64url_encode(claims.to_json().as_bytes());
let header_payload = format!("{}.{}", header, payload);
let signature = self.sign(&header_payload);
format!("{}.{}", header_payload, signature)
}
/// Decode and verify a JWT token.
pub fn decode(&self, token: &str) -> AuthResult<JwtClaims> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(AuthError::TokenInvalid("not a valid JWT".into()));
}
let header_payload = format!("{}.{}", parts[0], parts[1]);
let expected_sig = self.sign(&header_payload);
if !constant_time_eq(parts[2], &expected_sig) {
return Err(AuthError::TokenInvalid("signature mismatch".into()));
}
let payload_bytes = base64url_decode(parts[1])
.ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?;
let payload_str = String::from_utf8(payload_bytes)
.map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?;
let claims = JwtClaims::from_json(&payload_str)
.ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?;
if claims.is_expired() {
return Err(AuthError::TokenExpired);
}
Ok(claims)
}
}
impl AuthProvider for JwtProvider {
fn name(&self) -> &'static str {
"jwt"
}
fn verify(&self, token: &str) -> AuthResult<AuthContext> {
let claims = self.decode(token)?;
let user = AuthUser::new(&claims.sub, &claims.email, &claims.name);
Ok(AuthContext::authenticated(user, claims.roles, token))
}
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds);
Ok(self.encode(&claims))
}
fn revoke(&self, _token: &str) -> AuthResult<()> {
// JWTs are stateless — revocation requires a blocklist.
// TODO: maintain a revocation list (in-memory or Redis).
Ok(())
}
}
// ── Crypto helpers ─────────────────────────────────────────────────────────────
fn base64url_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(n & 63) as usize] as char);
}
}
out
}
fn base64url_decode(input: &str) -> Option<Vec<u8>> {
// Pad if needed
let mut s = input.replace('-', "+").replace('_', "/");
while s.len() % 4 != 0 {
s.push('=');
}
base64_decode_standard(&s)
}
fn base64_decode_standard(input: &str) -> Option<Vec<u8>> {
const TABLE: [u8; 128] = {
let mut t = [255u8; 128];
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0usize;
while i < chars.len() {
t[chars[i] as usize] = i as u8;
i += 1;
}
t
};
let input = input.trim_end_matches('=');
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?;
let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
out.push((n >> 16) as u8);
out.push((n >> 8) as u8);
out.push(n as u8);
i += 4;
}
// Handle remaining bytes
if i + 2 == bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
out.push(((a as u32) << 2 | (b as u32) >> 4) as u8);
} else if i + 3 == bytes.len() {
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2);
out.push((n >> 8) as u8);
out.push(n as u8);
}
Some(out)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ── Minimal JSON field extractors ─────────────────────────────────────────────
fn extract_str(json: &str, key: &str) -> Option<String> {
let pattern = format!("\"{}\":\"", key);
let start = json.find(&pattern)? + pattern.len();
let rest = &json[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn extract_u64(json: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = &json[start..];
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn extract_str_array(json: &str, key: &str) -> Vec<String> {
let pattern = format!("\"{}\":[", key);
let start = match json.find(&pattern) {
None => return Vec::new(),
Some(s) => s + pattern.len(),
};
let rest = &json[start..];
let end = rest.find(']').unwrap_or(rest.len());
let content = &rest[..end];
content
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches('"');
if s.is_empty() { None } else { Some(s.to_string()) }
})
.collect()
}
+68
View File
@@ -0,0 +1,68 @@
//! el-auth — Built-in authentication and authorization for el-ui.
//!
//! Not a library you add. Native to the framework.
//!
//! ```toml
//! [auth]
//! provider = "jwt"
//! jwt_secret_env = "JWT_SECRET"
//! session_store = "memory" # or "engram"
//! ```
//!
//! ## Engram-native sessions
//!
//! Use `EngramSessionStore` (in `engram_session`) for sessions backed by the
//! Engram identity graph. Sessions are graph nodes — server-side invalidation
//! works even with stateless JWTs.
pub mod context;
pub mod engram_session;
pub mod jwt;
pub mod middleware;
pub mod roles;
pub mod session;
pub use context::{AuthContext, AuthUser};
pub use engram_session::EngramSessionStore;
pub use jwt::{JwtClaims, JwtProvider};
pub use middleware::AuthMiddleware;
pub use roles::{Permission, Role, RoleRegistry};
pub use session::SessionProvider;
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("invalid credentials")]
InvalidCredentials,
#[error("token expired")]
TokenExpired,
#[error("token invalid: {0}")]
TokenInvalid(String),
#[error("session not found")]
SessionNotFound,
#[error("forbidden: requires permission '{0}'")]
Forbidden(String),
#[error("auth configuration error: {0}")]
Config(String),
}
pub type AuthResult<T> = Result<T, AuthError>;
/// The AuthProvider trait — implemented by JWT, Session, OAuth providers.
pub trait AuthProvider: Send + Sync {
/// The provider name (e.g., "jwt", "session").
fn name(&self) -> &'static str;
/// Verify a token/session string and return the auth context.
fn verify(&self, token: &str) -> AuthResult<AuthContext>;
/// Issue a new token/session for an authenticated user.
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
/// Revoke a token/session (for logout).
fn revoke(&self, token: &str) -> AuthResult<()>;
}
+201
View File
@@ -0,0 +1,201 @@
// el-auth Built-in authentication and authorization for el-ui.
//
// Engram-aware sessions: server-side invalidation works even with stateless
// JWTs because every session is also a graph node.
//
// Provider trait surface:
// verify(token) -> AuthContext
// issue(user, roles) -> token string
// revoke(token) -> Bool
// Errors
let ERR_INVALID_CREDS: String = "auth.invalid_credentials"
let ERR_TOKEN_EXPIRED: String = "auth.token_expired"
let ERR_TOKEN_INVALID: String = "auth.token_invalid"
let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found"
let ERR_FORBIDDEN: String = "auth.forbidden"
let ERR_CONFIG: String = "auth.config"
// AuthContext + AuthUser
type AuthUser {
id: String
email: String
display_name: String
}
type AuthContext {
user_id: String
email: String
roles: String // JSON array of role names
permissions: String // JSON array of permission strings
session_id: String
issued_at: String
expires_at: String
}
fn auth_context_empty() -> AuthContext {
{ "user_id": "", "email": "", "roles": "[]", "permissions": "[]",
"session_id": "", "issued_at": "", "expires_at": "" }
}
fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool {
str_contains(ctx.permissions, "\"" + perm + "\"")
}
fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool {
str_contains(ctx.roles, "\"" + role + "\"")
}
// Roles + permissions
type Permission {
resource: String
action: String
}
fn permission_new(resource: String, action: String) -> Permission {
{ "resource": resource, "action": action }
}
fn permission_string(p: Permission) -> String {
p.resource + ":" + p.action
}
// Role registry maps role name -> JSON array of permission strings.
fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool {
let raw: String = fs_read(registry_path)
let updated: String = json_array_append(raw, role, "\"" + perm + "\"")
fs_write(registry_path, updated)
}
// JWT (HS256)
type JwtClaims {
sub: String // user id
iss: String // issuer
aud: String // audience
iat: Int // issued at (unix seconds)
exp: Int // expires at (unix seconds)
jti: String // unique token id
}
fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims {
let now: Int = time_now_unix()
{ "sub": user_id, "iss": issuer, "aud": audience,
"iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() }
}
fn jwt_encode(claims: JwtClaims, secret: String) -> String {
let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
let payload_json: String = json_encode(claims)
let payload_b64: String = base64url_no_pad(payload_json)
let signing_input: String = header_b64 + "." + payload_b64
let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
signing_input + "." + sig
}
fn jwt_decode(token: String, secret: String) -> AuthContext {
let parts: String = token // [header].[payload].[sig]
let dot1: Int = str_index_of(parts, ".")
if dot1 < 0 { return auth_context_empty() }
let rest: String = str_slice(parts, dot1 + 1, str_len(parts))
let dot2: Int = str_index_of(rest, ".")
if dot2 < 0 { return auth_context_empty() }
let header_b64: String = str_slice(parts, 0, dot1)
let payload_b64: String = str_slice(rest, 0, dot2)
let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest))
let signing_input: String = header_b64 + "." + payload_b64
let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
if !str_eq(expected_sig, sig_b64) { return auth_context_empty() }
let payload_json: String = base64url_decode(payload_b64)
let now: Int = time_now_unix()
let exp: Int = str_to_int(json_get(payload_json, "exp"))
if exp < now { return auth_context_empty() }
{ "user_id": json_get(payload_json, "sub"),
"email": "",
"roles": "[]",
"permissions": "[]",
"session_id": json_get(payload_json, "jti"),
"issued_at": json_get(payload_json, "iat"),
"expires_at": json_get(payload_json, "exp") }
}
// Engram-backed session store
//
// Sessions are nodes of type "Session" connected to User via has_session.
// Revocation = node delete. Verification = node lookup + expiry check.
fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String {
let now: String = time_now_iso()
let exp: String = time_add_seconds(now, ttl_seconds)
let id: String = uuid_v4()
let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id
+ "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp
+ "\",\"ip_address\":\"" + ip + "\"}"
let node_id: String = engram_node_create("Session", body)
engram_edge_create(user_id, node_id, "has_session")
node_id
}
fn session_store_verify(session_id: String) -> Bool {
let raw: String = engram_node_get(session_id)
if str_eq(raw, "") { return false }
let exp: String = json_get(raw, "expires_at")
!time_after(time_now_iso(), exp)
}
fn session_store_revoke(session_id: String) -> Bool {
engram_node_delete(session_id)
}
// Middleware
//
// auth_middleware extracts the bearer token, decodes it, and populates the
// AuthContext. Applied automatically by the @authenticate aspect (el-aop).
fn extract_bearer(authorization_header: String) -> String {
if str_starts_with(authorization_header, "Bearer ") {
return str_slice(authorization_header, 7, str_len(authorization_header))
}
""
}
fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext {
let token: String = extract_bearer(authorization_header)
if str_eq(token, "") { return auth_context_empty() }
jwt_decode(token, jwt_secret)
}
fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool {
if str_eq(ctx.user_id, "") { return false }
auth_context_has_permission(ctx, required_perm)
}
// Provider issue/verify
fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String {
let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl)
jwt_encode(claims, jwt_secret)
}
fn provider_verify(token: String, jwt_secret: String) -> AuthContext {
let ctx: AuthContext = jwt_decode(token, jwt_secret)
if str_eq(ctx.user_id, "") { return ctx }
if !session_store_verify(ctx.session_id) { return auth_context_empty() }
ctx
}
fn provider_revoke(session_id: String) -> Bool {
session_store_revoke(session_id)
}
// Entry smoke test
let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" }
let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600)
println("[el-auth] issued JWT for " + user.email)
+59
View File
@@ -0,0 +1,59 @@
//! Auth middleware — extracts and verifies auth tokens from requests.
//!
//! In an axum application:
//! ```text
//! let app = Router::new()
//! .route("/api/users", get(list_users))
//! .layer(AuthMiddleware::new(jwt_provider));
//! ```
//!
//! The middleware populates `AuthContext` from the `Authorization` header.
use crate::{AuthContext, AuthProvider, AuthResult};
use std::sync::Arc;
/// Auth middleware — wraps an auth provider to extract context from HTTP headers.
pub struct AuthMiddleware {
provider: Arc<dyn AuthProvider>,
}
impl AuthMiddleware {
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
Self { provider }
}
/// Extract and verify the auth token from an Authorization header value.
///
/// Supported formats:
/// - `Bearer <token>` — JWT or opaque token
/// - `Session <session_id>` — server-side session
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
match authorization {
None => Ok(AuthContext::anonymous()),
Some(header) => {
let token = if let Some(t) = header.strip_prefix("Bearer ") {
t.trim()
} else if let Some(t) = header.strip_prefix("Session ") {
t.trim()
} else {
header.trim()
};
self.provider.verify(token)
}
}
}
/// Authenticate from a query parameter (for WebSocket upgrades where
/// Authorization headers can't be set from JS).
pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult<AuthContext> {
match token {
None => Ok(AuthContext::anonymous()),
Some(t) => self.provider.verify(t),
}
}
/// Get the underlying provider name.
pub fn provider_name(&self) -> &'static str {
self.provider.name()
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Role and permission model.
use std::collections::HashMap;
/// A fine-grained permission (e.g., "read", "write", "delete").
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Permission(pub String);
impl Permission {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A role that grants a set of permissions.
#[derive(Debug, Clone)]
pub struct Role {
pub name: String,
pub permissions: Vec<Permission>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(Permission::new(perm));
self
}
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> Self {
self.permissions.extend(perms.into_iter().map(Permission::new));
self
}
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p.0 == perm)
}
}
/// The registry of all roles in the application.
#[derive(Debug, Default)]
pub struct RoleRegistry {
roles: HashMap<String, Role>,
}
impl RoleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Register a role.
pub fn register(&mut self, role: Role) {
self.roles.insert(role.name.clone(), role);
}
/// Get a role by name.
pub fn get(&self, name: &str) -> Option<&Role> {
self.roles.get(name)
}
/// Check if the given role names grant the given permission.
pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool {
role_names.iter().any(|role_name| {
self.roles
.get(role_name)
.map(|r| r.has_permission(permission))
.unwrap_or(false)
})
}
/// List all registered role names.
pub fn role_names(&self) -> Vec<&str> {
self.roles.keys().map(|s| s.as_str()).collect()
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Session provider — server-side sessions stored in memory.
//!
//! In production, sessions are stored in Redis or Engram (configured via
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
//! This implementation uses in-memory storage for simplicity and testing.
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
struct SessionEntry {
context: AuthContext,
created_at: Instant,
ttl: Duration,
}
impl SessionEntry {
fn is_expired(&self) -> bool {
self.created_at.elapsed() > self.ttl
}
}
/// In-memory session store.
pub struct SessionProvider {
sessions: Mutex<HashMap<String, SessionEntry>>,
pub ttl: Duration,
}
impl SessionProvider {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
ttl: Duration::from_secs(3600),
}
}
pub fn with_ttl(mut self, seconds: u64) -> Self {
self.ttl = Duration::from_secs(seconds);
self
}
fn generate_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890)
}
/// Count active (non-expired) sessions.
pub fn active_session_count(&self) -> usize {
let sessions = self.sessions.lock().expect("session lock poisoned");
sessions.values().filter(|s| !s.is_expired()).count()
}
}
impl Default for SessionProvider {
fn default() -> Self {
Self::new()
}
}
impl AuthProvider for SessionProvider {
fn name(&self) -> &'static str {
"session"
}
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
let mut sessions = self.sessions.lock().expect("session lock poisoned");
// Clean expired sessions
sessions.retain(|_, v| !v.is_expired());
sessions
.get(session_id)
.filter(|s| !s.is_expired())
.map(|s| s.context.clone())
.ok_or(AuthError::SessionNotFound)
}
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
let session_id = Self::generate_session_id();
let ctx = AuthContext::authenticated(user, Vec::new(), &session_id);
let entry = SessionEntry {
context: ctx,
created_at: Instant::now(),
ttl: self.ttl,
};
self.sessions
.lock()
.expect("session lock poisoned")
.insert(session_id.clone(), entry);
Ok(session_id)
}
fn revoke(&self, session_id: &str) -> AuthResult<()> {
self.sessions
.lock()
.expect("session lock poisoned")
.remove(session_id);
Ok(())
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Tests for el-auth.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::{
context::{AuthContext, AuthUser},
jwt::{JwtClaims, JwtProvider},
middleware::AuthMiddleware,
roles::{Permission, Role, RoleRegistry},
session::SessionProvider,
AuthError, AuthProvider,
};
fn test_user() -> AuthUser {
AuthUser::new("user-1", "alice@example.com", "Alice")
}
fn test_provider() -> JwtProvider {
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
.with_ttl(3600)
}
fn registry() -> RoleRegistry {
let mut r = RoleRegistry::new();
r.register(
Role::new("admin")
.with_permissions(vec!["read", "write", "delete"]),
);
r.register(
Role::new("user")
.with_permissions(vec!["read"]),
);
r
}
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
#[test]
fn test_jwt_sign_and_verify() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["user"]);
}
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
#[test]
fn test_jwt_invalid_signature() {
let provider = test_provider();
let other_provider = JwtProvider::new(b"different-secret".to_vec());
let user = test_user();
let claims = JwtClaims::new(&user, vec![], 3600);
let token = other_provider.encode(&claims);
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
}
// ── Test 3: JWT expired token is rejected ────────────────────────────────
#[test]
fn test_jwt_expired_token() {
let provider = test_provider();
let user = test_user();
// TTL of 0 — expires immediately
let claims = JwtClaims::new(&user, vec![], 0);
let token = provider.encode(&claims);
// Wait a moment (in tests, just check the claims are_expired)
assert!(claims.is_expired() || {
std::thread::sleep(std::time::Duration::from_millis(1100));
true
});
let result = provider.decode(&token);
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
}
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
#[test]
fn test_jwt_verify_returns_auth_context() {
let provider = test_provider();
let user = test_user();
let reg = registry();
let token = provider.issue(user, &reg).unwrap();
let ctx = provider.verify(&token).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
#[test]
fn test_auth_context_has_role() {
let ctx = AuthContext::authenticated(
test_user(),
vec!["admin".into(), "user".into()],
"tok",
);
assert!(ctx.has_role("admin"));
assert!(ctx.has_role("user"));
assert!(!ctx.has_role("superadmin"));
}
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
#[test]
fn test_auth_context_has_permission() {
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
assert!(ctx.has_permission("read"));
assert!(ctx.has_permission("delete"));
assert!(!ctx.has_permission("sudo"));
}
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
#[test]
fn test_anonymous_context() {
let ctx = AuthContext::anonymous();
assert!(!ctx.is_authenticated());
assert!(ctx.user_id().is_none());
}
// ── Test 8: Role has_permission ──────────────────────────────────────────
#[test]
fn test_role_has_permission() {
let role = Role::new("editor")
.with_permissions(vec!["read", "write"]);
assert!(role.has_permission("read"));
assert!(role.has_permission("write"));
assert!(!role.has_permission("delete"));
}
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
#[test]
fn test_role_registry_permission_check() {
let reg = registry();
let roles = vec!["user".to_string()];
assert!(reg.has_permission(&roles, "read"));
assert!(!reg.has_permission(&roles, "delete"));
let admin_roles = vec!["admin".to_string()];
assert!(reg.has_permission(&admin_roles, "delete"));
}
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
#[test]
fn test_session_issue_and_verify() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
let ctx = provider.verify(&session_id).unwrap();
assert!(ctx.is_authenticated());
assert_eq!(ctx.user_id().unwrap(), "user-1");
}
// ── Test 11: SessionProvider revoke removes session ───────────────────────
#[test]
fn test_session_revoke() {
let provider = SessionProvider::new();
let reg = registry();
let session_id = provider.issue(test_user(), &reg).unwrap();
provider.revoke(&session_id).unwrap();
let result = provider.verify(&session_id);
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 12: SessionProvider unknown session returns error ────────────────
#[test]
fn test_session_unknown() {
let provider = SessionProvider::new();
let result = provider.verify("nonexistent-session-id");
assert!(matches!(result, Err(AuthError::SessionNotFound)));
}
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
#[test]
fn test_middleware_extracts_bearer_token() {
let provider = Arc::new(test_provider());
let user = test_user();
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
let token = provider.encode(&claims);
let middleware = AuthMiddleware::new(provider);
let header = format!("Bearer {}", token);
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
assert!(ctx.is_authenticated());
}
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
#[test]
fn test_middleware_no_header_anonymous() {
let provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(provider);
let ctx = middleware.authenticate_from_header(None).unwrap();
assert!(!ctx.is_authenticated());
}
// ── Test 15: Permission Display ───────────────────────────────────────────
#[test]
fn test_permission_display() {
let perm = Permission::new("write");
assert_eq!(perm.to_string(), "write");
assert_eq!(perm.as_str(), "write");
}
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
#[test]
fn test_jwt_claims_json_round_trip() {
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let json = claims.to_json();
let decoded = JwtClaims::from_json(&json).unwrap();
assert_eq!(decoded.sub, "user-1");
assert_eq!(decoded.email, "alice@example.com");
assert_eq!(decoded.roles, vec!["admin", "user"]);
}
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
#[test]
fn test_jwt_multiple_roles() {
let provider = test_provider();
let user = test_user();
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
let token = provider.encode(&claims);
let decoded = provider.decode(&token).unwrap();
assert_eq!(decoded.roles.len(), 2);
assert!(decoded.roles.contains(&"admin".to_string()));
}
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
#[test]
fn test_role_registry_names() {
let reg = registry();
let mut names = reg.role_names();
names.sort();
assert_eq!(names, vec!["admin", "user"]);
}
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
#[test]
fn test_session_ttl_config() {
let provider = SessionProvider::new().with_ttl(7200);
assert_eq!(provider.ttl.as_secs(), 7200);
}
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
#[test]
fn test_middleware_provider_name() {
let jwt_provider = Arc::new(test_provider());
let middleware = AuthMiddleware::new(jwt_provider);
assert_eq!(middleware.provider_name(), "jwt");
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "el-config"
version = "0.1.0"
edition = "2021"
description = "el-ui configuration system — layered, typed, environment-aware"
license = "MIT"
[lib]
name = "el_config"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
[dev-dependencies]
+15
View File
@@ -0,0 +1,15 @@
vessel "el-config" {
version "0.1.0"
description "Layered, typed configuration: env, dotenv, manifest, defaults"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+337
View File
@@ -0,0 +1,337 @@
/// Config — layered configuration with typed access.
///
/// Sources are stacked in priority order. The first source to provide a value
/// for a key wins. Resolution order:
/// 1. Environment variables (highest)
/// 2. .env file (dev only)
/// 3. el.toml [env.<current>] section
/// 4. el.toml [config] section (base)
/// 5. Defaults defined in code (lowest)
use std::collections::HashMap;
use crate::error::ConfigError;
use crate::source::{ConfigSource, FromConfigStr, MapSource};
use crate::env::Environment;
/// The main configuration object.
///
/// Holds a stack of sources and resolves keys through them in order.
pub struct Config {
/// Sources in descending priority order (index 0 = highest priority).
sources: Vec<Box<dyn ConfigSource>>,
/// Environment in effect.
pub environment: Environment,
}
impl Config {
/// Create an empty config with no sources.
pub fn new(env: Environment) -> Self {
Self {
sources: Vec::new(),
environment: env,
}
}
/// Build with the default source stack for an application:
/// env vars > defaults map.
pub fn default_stack() -> Self {
use crate::source::EnvVarSource;
let env = Environment::current();
let mut config = Self::new(env);
config.push_source(Box::new(EnvVarSource::new()));
config
}
/// Add a source at the lowest priority (end of the stack).
pub fn push_source(&mut self, source: Box<dyn ConfigSource>) {
self.sources.push(source);
}
/// Add a source at the highest priority (beginning of the stack).
pub fn prepend_source(&mut self, source: Box<dyn ConfigSource>) {
self.sources.insert(0, source);
}
/// Add defaults as the lowest-priority source.
pub fn set_defaults(&mut self, defaults: HashMap<String, String>) {
let src = MapSource::from_map("defaults", defaults);
self.sources.push(Box::new(src));
}
/// Get a raw string value for a key.
pub fn get_raw(&self, key: &str) -> Option<String> {
for source in &self.sources {
if let Some(val) = source.get_raw(key) {
return Some(val);
}
}
None
}
/// Get a typed value for a key.
///
/// Returns an error if the key is not found or can't be parsed.
pub fn get<T: FromConfigStr>(&self, key: &str) -> Result<T, ConfigError> {
let raw = self.get_raw(key).ok_or_else(|| ConfigError::NotFound {
key: key.to_string(),
})?;
T::from_config_str(&raw).map_err(|e| {
// Inject the key into TypeMismatch errors
match e {
ConfigError::TypeMismatch { expected, got, .. } => {
ConfigError::TypeMismatch {
key: key.to_string(),
expected,
got,
}
}
other => other,
}
})
}
/// Get a typed value with a fallback default.
pub fn get_or<T: FromConfigStr>(&self, key: &str, default: T) -> T {
self.get(key).unwrap_or(default)
}
/// Get an optional typed value. Returns None if not set (not an error).
pub fn get_opt<T: FromConfigStr>(&self, key: &str) -> Result<Option<T>, ConfigError> {
match self.get_raw(key) {
None => Ok(None),
Some(raw) => T::from_config_str(&raw)
.map(Some)
.map_err(|e| match e {
ConfigError::TypeMismatch { expected, got, .. } => {
ConfigError::TypeMismatch {
key: key.to_string(),
expected,
got,
}
}
other => other,
}),
}
}
/// All key→value pairs from all sources (merged, highest-priority wins).
pub fn all(&self) -> HashMap<String, String> {
let mut result = HashMap::new();
// Iterate in reverse order (lowest priority first) so higher-priority
// sources overwrite lower-priority ones.
for source in self.sources.iter().rev() {
for (k, v) in source.all() {
result.insert(k, v);
}
}
result
}
}
/// Load config from an `el.toml` string.
///
/// Reads `[config]` as the base, then overlays `[env.<environment>]`.
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
let value: toml::Value = toml::from_str(toml_str)
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
let mut map = HashMap::new();
// Load base [config] section
if let Some(config_section) = value.get("config") {
if let Some(table) = config_section.as_table() {
flatten_toml_table(table, "", &mut map);
}
}
// Overlay [env.<name>] section
let env_key = env.name();
if let Some(env_sections) = value.get("env") {
if let Some(env_table) = env_sections.get(env_key) {
if let Some(table) = env_table.as_table() {
flatten_toml_table(table, "", &mut map);
}
}
}
Ok(MapSource::from_map("el.toml", map))
}
fn flatten_toml_table(
table: &toml::value::Table,
prefix: &str,
out: &mut HashMap<String, String>,
) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
toml::Value::String(s) => {
out.insert(full_key, s.clone());
}
toml::Value::Integer(i) => {
out.insert(full_key, i.to_string());
}
toml::Value::Float(f) => {
out.insert(full_key, f.to_string());
}
toml::Value::Boolean(b) => {
out.insert(full_key, b.to_string());
}
toml::Value::Table(t) => {
flatten_toml_table(t, &full_key, out);
}
_ => {} // Arrays, datetimes: skip for now
}
}
}
/// Macro for typed config access on a global/injected Config.
///
/// ```ignore
/// let name = config!(cfg, "app.name", String);
/// let port = config!(cfg, "server.port", u32, 8080);
/// ```
#[macro_export]
macro_rules! config {
($cfg:expr, $key:expr, $type:ty) => {
$cfg.get::<$type>($key)
};
($cfg:expr, $key:expr, $type:ty, $default:expr) => {
$cfg.get_or::<$type>($key, $default)
};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::MapSource;
fn make_config(pairs: &[(&str, &str)]) -> Config {
let mut src = MapSource::new("test");
for (k, v) in pairs {
src.insert(*k, *v);
}
let mut cfg = Config::new(Environment::Development);
cfg.push_source(Box::new(src));
cfg
}
#[test]
fn get_string() {
let cfg = make_config(&[("app.name", "TestApp")]);
assert_eq!(cfg.get::<String>("app.name").unwrap(), "TestApp");
}
#[test]
fn get_u32() {
let cfg = make_config(&[("server.port", "8080")]);
assert_eq!(cfg.get::<u32>("server.port").unwrap(), 8080u32);
}
#[test]
fn get_bool() {
let cfg = make_config(&[("feature.enabled", "true")]);
assert_eq!(cfg.get::<bool>("feature.enabled").unwrap(), true);
}
#[test]
fn get_missing_returns_error() {
let cfg = Config::new(Environment::Development);
assert!(cfg.get::<String>("missing.key").is_err());
}
#[test]
fn get_or_default() {
let cfg = Config::new(Environment::Development);
assert_eq!(cfg.get_or("timeout", 30u32), 30u32);
}
#[test]
fn get_or_prefers_source() {
let cfg = make_config(&[("timeout", "60")]);
assert_eq!(cfg.get_or("timeout", 30u32), 60u32);
}
#[test]
fn higher_priority_source_wins() {
let mut cfg = Config::new(Environment::Development);
let mut low = MapSource::new("low");
low.insert("key", "low-value");
let mut high = MapSource::new("high");
high.insert("key", "high-value");
cfg.push_source(Box::new(high));
cfg.push_source(Box::new(low));
// First source (index 0) is highest priority
assert_eq!(cfg.get::<String>("key").unwrap(), "high-value");
}
#[test]
fn get_opt_missing_is_none() {
let cfg = Config::new(Environment::Development);
assert_eq!(cfg.get_opt::<String>("missing").unwrap(), None);
}
#[test]
fn get_opt_present_is_some() {
let cfg = make_config(&[("key", "value")]);
assert_eq!(
cfg.get_opt::<String>("key").unwrap(),
Some("value".to_string())
);
}
#[test]
fn load_from_toml_base() {
let toml = r#"
[config]
app.name = "MyApp"
app.version = "1.0.0"
"#;
let src = load_from_toml(toml, &Environment::Development).unwrap();
assert_eq!(src.get_raw("app.name"), Some("MyApp".to_string()));
}
#[test]
fn load_from_toml_env_overlay() {
let toml = r#"
[config]
api.base_url = "https://api.example.com"
[env.development]
api.base_url = "http://localhost:8080"
"#;
let src = load_from_toml(toml, &Environment::Development).unwrap();
assert_eq!(
src.get_raw("api.base_url"),
Some("http://localhost:8080".to_string())
);
}
#[test]
fn load_from_toml_env_does_not_override_in_prod() {
let toml = r#"
[config]
api.base_url = "https://api.example.com"
[env.development]
api.base_url = "http://localhost:8080"
"#;
let src = load_from_toml(toml, &Environment::Production).unwrap();
assert_eq!(
src.get_raw("api.base_url"),
Some("https://api.example.com".to_string())
);
}
#[test]
fn load_from_toml_invalid() {
let result = load_from_toml("not valid toml %%%", &Environment::Development);
assert!(result.is_err());
}
}
+114
View File
@@ -0,0 +1,114 @@
/// Environment detection — which deployment context are we in?
/// The current deployment environment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Environment {
/// Local developer machine. Verbose errors, hot reload, relaxed auth.
Development,
/// Pre-production environment. Production build, test data.
Staging,
/// Live production. Minimal logging, strict auth, performance mode.
Production,
}
impl Environment {
/// Detect from the `EL_ENV` environment variable (or `APP_ENV`, `RUST_ENV`).
///
/// Falls back to Development if unset or unrecognized.
pub fn current() -> Self {
let val = std::env::var("EL_ENV")
.or_else(|_| std::env::var("APP_ENV"))
.or_else(|_| std::env::var("RUST_ENV"))
.unwrap_or_default();
Self::from_str(&val)
}
/// Parse from a string.
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"production" | "prod" => Environment::Production,
"staging" | "stage" => Environment::Staging,
_ => Environment::Development,
}
}
/// The canonical name for this environment.
pub fn name(&self) -> &'static str {
match self {
Environment::Development => "development",
Environment::Staging => "staging",
Environment::Production => "production",
}
}
/// Whether this is a production environment.
pub fn is_production(&self) -> bool {
matches!(self, Environment::Production)
}
/// Whether this is a development environment.
pub fn is_development(&self) -> bool {
matches!(self, Environment::Development)
}
/// Whether debug features should be enabled.
pub fn debug_enabled(&self) -> bool {
!self.is_production()
}
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_production() {
assert_eq!(Environment::from_str("production"), Environment::Production);
assert_eq!(Environment::from_str("prod"), Environment::Production);
assert_eq!(Environment::from_str("PRODUCTION"), Environment::Production);
}
#[test]
fn parse_staging() {
assert_eq!(Environment::from_str("staging"), Environment::Staging);
assert_eq!(Environment::from_str("stage"), Environment::Staging);
}
#[test]
fn parse_development_fallback() {
assert_eq!(Environment::from_str("dev"), Environment::Development);
assert_eq!(Environment::from_str(""), Environment::Development);
assert_eq!(Environment::from_str("unknown"), Environment::Development);
}
#[test]
fn production_is_production() {
assert!(Environment::Production.is_production());
assert!(!Environment::Development.is_production());
}
#[test]
fn development_debug_enabled() {
assert!(Environment::Development.debug_enabled());
assert!(!Environment::Production.debug_enabled());
}
#[test]
fn environment_name() {
assert_eq!(Environment::Production.name(), "production");
assert_eq!(Environment::Staging.name(), "staging");
assert_eq!(Environment::Development.name(), "development");
}
#[test]
fn display() {
assert_eq!(format!("{}", Environment::Production), "production");
}
}
+20
View File
@@ -0,0 +1,20 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config key '{key}' not found")]
NotFound { key: String },
#[error("config key '{key}': expected {expected}, got '{got}'")]
TypeMismatch {
key: String,
expected: String,
got: String,
},
#[error("config parse error: {0}")]
ParseError(String),
#[error("config source error '{source_name}': {message}")]
SourceError { source_name: String, message: String },
}
+42
View File
@@ -0,0 +1,42 @@
//! el-config — Layered, typed configuration for el-ui applications.
//!
//! ## Resolution order (highest priority wins)
//!
//! 1. Environment variables (`EL_APP_NAME=...`)
//! 2. `.env` file (development only)
//! 3. `el.toml` `[env.<current>]` section
//! 4. `el.toml` `[config]` base section
//! 5. Defaults defined in code
//!
//! ## Quick start
//!
//! ```
//! use el_config::prelude::*;
//!
//! let mut cfg = Config::new(Environment::Development);
//! let mut defaults = std::collections::HashMap::new();
//! defaults.insert("app.name".to_string(), "MyApp".to_string());
//! defaults.insert("server.port".to_string(), "8080".to_string());
//! cfg.set_defaults(defaults);
//!
//! let name = cfg.get::<String>("app.name").unwrap();
//! let port = cfg.get::<u32>("server.port").unwrap();
//! assert_eq!(name, "MyApp");
//! assert_eq!(port, 8080);
//! ```
#![deny(warnings)]
pub mod config;
pub mod env;
pub mod error;
pub mod source;
pub mod prelude {
pub use crate::config::{load_from_toml, Config};
pub use crate::env::Environment;
pub use crate::error::ConfigError;
pub use crate::source::{ConfigSource, EnvVarSource, FromConfigStr, MapSource};
}
pub use prelude::*;
+177
View File
@@ -0,0 +1,177 @@
// el-config Layered, typed configuration for el-ui.
//
// Each layer is a key->value map. `Config::get(key)` walks layers from highest
// to lowest priority and returns the first hit. Values are parsed into the
// caller-requested type via `parse_<type>`.
// Environment
let ENV_DEVELOPMENT: String = "development"
let ENV_STAGING: String = "staging"
let ENV_PRODUCTION: String = "production"
let ENV_TEST: String = "test"
fn env_current() -> String {
let raw: String = env("EL_ENV")
if str_eq(raw, "") { return ENV_DEVELOPMENT }
raw
}
fn env_is_production(name: String) -> Bool {
str_eq(name, "production")
}
// Errors
let CFG_ERR_MISSING: String = "config.missing"
let CFG_ERR_PARSE: String = "config.parse_error"
let CFG_ERR_TYPE: String = "config.type_error"
// Sources
let SRC_ENV: String = "env"
let SRC_DOTENV: String = "dotenv"
let SRC_MANIFEST: String = "manifest"
let SRC_DEFAULTS: String = "defaults"
let SRC_MAP: String = "map"
type ConfigSource {
kind: String
priority: Int // higher = wins
map_json: String // JSON: key -> string value
}
fn source_env_vars(prefix: String) -> ConfigSource {
// Snapshot env at construction (the runtime exposes env_keys()).
let map: String = "{}"
let keys: String = env_keys_with_prefix(prefix)
let n: Int = json_array_len(keys)
let i: Int = 0
while i < n {
let raw_key: String = json_array_get(keys, i)
let value: String = env(raw_key)
let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", "."))
let map = json_set(map, logical_key, "\"" + value + "\"")
let i = i + 1
}
{ "kind": "env", "priority": 100, "map_json": map }
}
fn source_dotenv(path: String) -> ConfigSource {
let raw: String = ""
if fs_exists(path) { let raw = fs_read(path) }
let map: String = dotenv_parse(raw)
{ "kind": "dotenv", "priority": 80, "map_json": map }
}
fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource {
let raw: String = fs_read(manifest_path)
let base: String = manifest_section(raw, "config")
let env_key: String = "env." + current_env
let env_overrides: String = manifest_section(raw, env_key)
let map: String = json_merge(base, env_overrides)
{ "kind": "manifest", "priority": 60, "map_json": map }
}
fn source_defaults(map_json: String) -> ConfigSource {
{ "kind": "defaults", "priority": 0, "map_json": map_json }
}
// Config
type Config {
environment: String // development | staging | production | test
sources_json: String // JSON array of ConfigSource
}
fn config_new(env_name: String) -> Config {
{ "environment": env_name, "sources_json": "[]" }
}
fn config_add_source(c: Config, src: ConfigSource) -> Config {
let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true)
{ "environment": c.environment, "sources_json": updated }
}
fn config_set_defaults(c: Config, defaults_map: String) -> Config {
config_add_source(c, source_defaults(defaults_map))
}
fn config_get_string(c: Config, key: String) -> String {
let n: Int = json_array_len(c.sources_json)
let i: Int = 0
while i < n {
let src_json: String = json_array_get(c.sources_json, i)
let map: String = json_get(src_json, "map_json")
let v: String = json_get(map, key)
if !str_eq(v, "") { return v }
let i = i + 1
}
""
}
fn config_get_int(c: Config, key: String) -> Int {
let raw: String = config_get_string(c, key)
if str_eq(raw, "") { return 0 }
str_to_int(raw)
}
fn config_get_bool(c: Config, key: String) -> Bool {
let raw: String = str_to_lower(config_get_string(c, key))
if str_eq(raw, "true") { return true }
if str_eq(raw, "1") { return true }
if str_eq(raw, "yes") { return true }
false
}
// Strict variants non-empty required.
fn config_require_string(c: Config, key: String) -> String {
let v: String = config_get_string(c, key)
if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) }
v
}
// load_from_toml convenience for TOML config files
fn config_load_from_toml(path: String, env_name: String) -> Config {
let cfg: Config = config_new(env_name)
let raw: String = fs_read(path)
let map: String = toml_to_json_flat(raw) // dotted keys
let cfg = config_add_source(cfg, source_defaults(map))
cfg
}
// .env parser (minimal)
fn dotenv_parse(raw: String) -> String {
let map: String = "{}"
let lines: String = str_split(raw, "\n")
let n: Int = json_array_len(lines)
let i: Int = 0
while i < n {
let line: String = str_trim(json_array_get(lines, i))
if str_eq(line, "") {
let i = i + 1
}
if !str_eq(line, "") {
if !str_starts_with(line, "#") {
let eq: Int = str_index_of(line, "=")
if eq > 0 {
let k: String = str_trim(str_slice(line, 0, eq))
let v: String = str_trim(str_slice(line, eq + 1, str_len(line)))
let v = str_strip_quotes(v)
let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"")
}
let i = i + 1
}
}
}
map
}
// Entry smoke test
let cfg: Config = config_new(env_current())
let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}"
let cfg = config_set_defaults(cfg, defaults)
println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name"))
+248
View File
@@ -0,0 +1,248 @@
/// ConfigSource trait and implementations.
///
/// Each source provides key→value pairs. Sources are stacked in priority order;
/// the Config struct resolves by asking each source in turn.
use std::collections::HashMap;
use crate::error::ConfigError;
/// A source of configuration values.
pub trait ConfigSource: Send + Sync {
/// The name of this source (for debugging/error messages).
fn name(&self) -> &str;
/// Get a raw string value for a key.
/// Returns None if this source doesn't have the key.
fn get_raw(&self, key: &str) -> Option<String>;
/// All key→value pairs from this source.
fn all(&self) -> HashMap<String, String>;
}
/// Reads from environment variables.
///
/// Keys are mapped: `app.name` → `EL_APP_NAME` (uppercased, dots → underscores).
pub struct EnvVarSource {
/// Optional prefix. Default: "EL".
prefix: String,
}
impl EnvVarSource {
pub fn new() -> Self {
Self { prefix: "EL".to_string() }
}
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self { prefix: prefix.into() }
}
fn env_key(&self, key: &str) -> String {
let normalized = key.replace('.', "_").replace('-', "_").to_uppercase();
format!("{}_{}", self.prefix, normalized)
}
}
impl Default for EnvVarSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for EnvVarSource {
fn name(&self) -> &str {
"environment"
}
fn get_raw(&self, key: &str) -> Option<String> {
std::env::var(self.env_key(key)).ok()
}
fn all(&self) -> HashMap<String, String> {
let prefix = format!("{}_", self.prefix);
std::env::vars()
.filter(|(k, _)| k.starts_with(&prefix))
.map(|(k, v)| {
let stripped = k.strip_prefix(&prefix).unwrap_or(&k);
let config_key = stripped.to_lowercase().replace('_', ".");
(config_key, v)
})
.collect()
}
}
/// Holds an in-memory map of config values.
///
/// Used for defaults defined in code, or for config loaded from a parsed
/// TOML/JSON file section.
pub struct MapSource {
name: String,
values: HashMap<String, String>,
}
impl MapSource {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
values: HashMap::new(),
}
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn from_map(name: impl Into<String>, map: HashMap<String, String>) -> Self {
Self {
name: name.into(),
values: map,
}
}
}
impl ConfigSource for MapSource {
fn name(&self) -> &str {
&self.name
}
fn get_raw(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
fn all(&self) -> HashMap<String, String> {
self.values.clone()
}
}
/// Typed config value extractor.
pub trait FromConfigStr: Sized {
fn from_config_str(s: &str) -> Result<Self, ConfigError>;
}
impl FromConfigStr for String {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
Ok(s.to_string())
}
}
impl FromConfigStr for u32 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "u32".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for u64 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "u64".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for i32 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "i32".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for i64 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "i64".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for f32 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "f32".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for f64 {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
s.parse().map_err(|_| ConfigError::TypeMismatch {
key: String::new(),
expected: "f64".to_string(),
got: s.to_string(),
})
}
}
impl FromConfigStr for bool {
fn from_config_str(s: &str) -> Result<Self, ConfigError> {
match s.to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(ConfigError::TypeMismatch {
key: String::new(),
expected: "bool".to_string(),
got: s.to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_source_get() {
let mut src = MapSource::new("test");
src.insert("app.name", "TestApp");
assert_eq!(src.get_raw("app.name"), Some("TestApp".to_string()));
}
#[test]
fn map_source_missing() {
let src = MapSource::new("test");
assert_eq!(src.get_raw("no.key"), None);
}
#[test]
fn bool_from_config_str() {
assert_eq!(bool::from_config_str("true").unwrap(), true);
assert_eq!(bool::from_config_str("1").unwrap(), true);
assert_eq!(bool::from_config_str("false").unwrap(), false);
assert_eq!(bool::from_config_str("0").unwrap(), false);
}
#[test]
fn u32_from_config_str() {
assert_eq!(u32::from_config_str("42").unwrap(), 42u32);
}
#[test]
fn u32_type_mismatch() {
assert!(u32::from_config_str("not-a-number").is_err());
}
#[test]
fn env_key_mapping() {
let src = EnvVarSource::new();
// app.name → EL_APP_NAME
assert_eq!(src.env_key("app.name"), "EL_APP_NAME");
}
#[test]
fn env_source_name() {
let src = EnvVarSource::new();
assert_eq!(src.name(), "environment");
}
}
+17
View File
@@ -0,0 +1,17 @@
vessel "el-graph" {
version "0.1.0"
description "Graph rendering and editor vessel for el-ui"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-style "1.0"
el-layout "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+46
View File
@@ -0,0 +1,46 @@
// canvas.el Full server-side pipeline: layout -> render -> SVG string.
//
// This is the primary integration point for callers that want a static SVG
// without managing the layout and render steps separately.
//
// Public API:
// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String
// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string.
// Returns a complete <svg>...</svg> string.
//
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
// gains a JavaScript backend. For now, all rendering is server-side.
// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg).
//
// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS
// backend lands, el-ui-compiler will produce an overlay with pointer-event
// handlers that transform a <g> wrapper inside this SVG. The server-side path
// stays as a fallback for non-browser consumers (CLI, PDF export, testing).
fn layout_default_iterations() -> Int { 150 }
// graph_svg_endpoint
fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String {
let w_f: Float = int_to_float(width)
let h_f: Float = int_to_float(height)
// Step 1: compute layout
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations())
// Step 2: render to SVG
let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height)
svg
}
// graph_svg_endpoint_custom
//
// Same as above but with configurable iteration count.
// Use when you need faster layout (low iters) or higher quality (high iters).
fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String {
let w_f: Float = int_to_float(width)
let h_f: Float = int_to_float(height)
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations)
graph_render_svg(nodes_json, edges_json, positions_json, width, height)
}
+44
View File
@@ -0,0 +1,44 @@
// edge.el Edge type definitions and visual encoding.
//
// Edges are directed (source -> target) with a weight and optional relation label.
// Edge JSON accessors
//
// Edges are passed as JSON objects: { source_id, target_id, weight, relation }
fn edge_source(e_json: String) -> String {
let s: String = json_get_string(e_json, "source_id")
if !str_eq(s, "") { return s }
json_get_string(e_json, "source")
}
fn edge_target(e_json: String) -> String {
let t: String = json_get_string(e_json, "target_id")
if !str_eq(t, "") { return t }
json_get_string(e_json, "target")
}
fn edge_weight(e_json: String) -> Float {
let w: Float = json_get_float(e_json, "weight")
if w == int_to_float(0) { return int_to_float(1) }
w
}
fn edge_relation(e_json: String) -> String {
json_get_string(e_json, "relation")
}
// Edge visual encoding
// Stroke width clamped to [1, 4] based on weight.
fn edge_stroke_width(weight: Float) -> Float {
let min_w: Float = int_to_float(1)
let max_w: Float = int_to_float(4)
let range: Float = max_w - min_w
let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } }
clamped
}
fn edge_stroke_color() -> String { "#3a4a5a" }
fn edge_stroke_color_highlight() -> String { "#5a7a9a" }
+138
View File
@@ -0,0 +1,138 @@
// editor.el Round-trip graph editing API.
//
// Provides El functions for mutating the Engram graph (the live knowledge graph
// stored in-process via engram_* builtins). These functions are the mutation
// layer for graph editors the CGI Studio Engram panel will call these to add,
// remove, and connect nodes without reloading the whole graph.
//
// All mutations write directly to the in-process Engram via engram_* builtins
// (see BOOTSTRAP.md §Engram Knowledge Graph).
//
// Drag interaction is NOT implemented here that requires pointer-event
// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the
// move_node() position cache to the layout state keys used in layout.el.
//
// Public API:
// graph_add_node(content, node_type, label, salience) -> String // node_id or ""
// graph_remove_node(node_id) -> String // "ok" or error JSON
// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON
// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON
// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache)
// graph_add_node
fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String {
if str_eq(content, "") {
return "{\"error\":\"content is required\"}"
}
let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) }
// engram_node_full: content, type, label, salience, importance, confidence, tier, tags
let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label }
let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type }
let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "")
if str_eq(node_id, "") {
return "{\"error\":\"engram_node_full returned empty id\"}"
}
"{\"id\":\"" + node_id + "\"}"
}
// graph_remove_node
fn graph_remove_node(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
// Check node exists
let existing: String = engram_get_node(node_id)
if str_eq(existing, "") {
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
}
engram_forget(node_id)
"{\"ok\":true,\"id\":\"" + node_id + "\"}"
}
// graph_add_edge
fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String {
if str_eq(from_id, "") {
return "{\"error\":\"from_id is required\"}"
}
if str_eq(to_id, "") {
return "{\"error\":\"to_id is required\"}"
}
let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) }
let rel: String = if str_eq(relation, "") { "relates_to" } else { relation }
// engram_connect(from, to, weight, relation)
let edge_id: String = engram_connect(from_id, to_id, w, rel)
if str_eq(edge_id, "") {
return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
"{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// graph_remove_edge
fn graph_remove_edge(from_id: String, to_id: String) -> String {
if str_eq(from_id, "") {
return "{\"error\":\"from_id is required\"}"
}
if str_eq(to_id, "") {
return "{\"error\":\"to_id is required\"}"
}
// Check edge exists
let existing: String = engram_edge_between(from_id, to_id)
if str_eq(existing, "") {
return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// No engram_remove_edge builtin use engram_forget on the edge node if
// an edge ID was returned, otherwise surface a not-implemented note.
// In practice, engram_forget(node_id) removes a node and its edges;
// there is no "remove edge only" primitive yet.
"{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// graph_move_node
//
// Caches a node's screen position for use by the layout engine.
// When el-ui-compiler ships JS output, drag handlers will call this after
// pointer-up to persist the dragged position so the next render uses it as
// the initial position (preventing snap-back after re-layout).
fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
// Store in process state layout.el reads these keys as initial positions.
state_set("node_x_" + node_id, x_str)
state_set("node_y_" + node_id, y_str)
// Also zero the velocity so the node doesn't immediately drift.
state_set("node_vx_" + node_id, "0.0")
state_set("node_vy_" + node_id, "0.0")
"{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}"
}
// graph_node_info
//
// Retrieve full node data from Engram (for inspector panels).
fn graph_node_info(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
let n: String = engram_get_node(node_id)
if str_eq(n, "") {
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
}
n
}
// graph_neighbors
//
// Return the neighbors of a node as a JSON array (for sub-graph drill-down).
fn graph_neighbors_json(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
engram_neighbors(node_id)
}
+352
View File
@@ -0,0 +1,352 @@
// layout.el Force-directed layout engine (pure El math).
//
// Implements a basic spring-force simulation:
// - Coulomb repulsion between every pair of nodes (O(n²))
// - Hooke spring attraction along edges
// - Weak gravity toward the canvas center
// - Velocity damping per iteration
//
// Float representation: El stores floats as bit-cast int64_t values.
// All math uses int_to_float() for literals and math_sqrt() for sqrt.
//
// Public API:
// layout_run(nodes_json, edges_json, width, height, iterations) -> String
// nodes_json JSON array: [{ id, salience, ... }, ...]
// edges_json JSON array: [{ source_id, target_id, weight }, ...]
// width/height canvas Float dimensions
// iterations simulation steps (default 150 for good convergence)
// Returns JSON array: [{ id, x, y }, ...]
// Constants
fn layout_repulsion_k() -> Float {
// Coulomb constant controls node spread.
int_to_float(3000)
}
fn layout_spring_k() -> Float {
// Spring stiffness for edge attraction.
int_to_float(1)
}
fn layout_spring_rest() -> Float {
// Rest length for edges (px).
int_to_float(80)
}
fn layout_gravity_k() -> Float {
// Gravity toward center (gentle).
int_to_float(1)
}
fn layout_damping() -> Float {
// Velocity decay per step (0.85 = 15% loss per step).
let d: Float = int_to_float(85)
d / int_to_float(100)
}
fn layout_max_velocity() -> Float {
// Cap velocity per step to avoid explosion.
int_to_float(50)
}
fn layout_min_dist() -> Float {
// Minimum distance to prevent division by zero in repulsion.
int_to_float(1)
}
// State keys (process state for per-node data)
//
// We use process state (state_set/state_get) as a flat key/value store since
// El does not have mutable arrays or map mutation without re-assignment.
// Key patterns:
// "node_ids" comma-separated node id list
// "node_x_<id>" x position
// "node_y_<id>" y position
// "node_vx_<id>" x velocity
// "node_vy_<id>" y velocity
fn layout_key_x(node_id: String) -> String { "node_x_" + node_id }
fn layout_key_y(node_id: String) -> String { "node_y_" + node_id }
fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id }
fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id }
// Initialization
//
// Distribute nodes in a circle around the center so no two start at the
// same position (which would make repulsion forces zero and give no movement).
fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let count: Int = el_list_len(ids)
if count == 0 { return true }
let pi2: Float = math_pi() * int_to_float(2)
let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count)
let i: Int = 0
while i < count {
let id: String = el_list_get(ids, i)
let angle: Float = pi2 * int_to_float(i) / int_to_float(count)
let x: Float = cx + radius * math_cos(angle)
let y: Float = cy + math_sin(angle) * radius
state_set(layout_key_x(id), float_to_str(x))
state_set(layout_key_y(id), float_to_str(x))
state_set(layout_key_y(id), float_to_str(y))
state_set(layout_key_vx(id), "0.0")
state_set(layout_key_vy(id), "0.0")
let i = i + 1
}
true
}
// Float helpers
fn layout_get_x(id: String) -> Float {
str_to_float(state_get(layout_key_x(id)))
}
fn layout_get_y(id: String) -> Float {
str_to_float(state_get(layout_key_y(id)))
}
fn layout_get_vx(id: String) -> Float {
str_to_float(state_get(layout_key_vx(id)))
}
fn layout_get_vy(id: String) -> Float {
str_to_float(state_get(layout_key_vy(id)))
}
fn float_clamp(v: Float, lo: Float, hi: Float) -> Float {
if v < lo { return lo }
if v > hi { return hi }
v
}
fn float_abs(v: Float) -> Float {
if v < int_to_float(0) { return int_to_float(0) - v }
v
}
// Repulsion pass
//
// For each pair (a, b): compute Coulomb repulsion and accumulate forces.
// Force direction: along the vector from b to a (a is pushed away from b).
// Magnitude: k / dist^2
fn layout_repulsion_pass(node_ids: String) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let i: Int = 0
while i < n {
let id_a: String = el_list_get(ids, i)
let ax: Float = layout_get_x(id_a)
let ay: Float = layout_get_y(id_a)
let fx: Float = int_to_float(0)
let fy: Float = int_to_float(0)
let j: Int = 0
while j < n {
if j != i {
let id_b: String = el_list_get(ids, j)
let bx: Float = layout_get_x(id_b)
let by: Float = layout_get_y(id_b)
let dx: Float = ax - bx
let dy: Float = ay - by
let dist_sq: Float = dx * dx + dy * dy
let dist: Float = math_sqrt(dist_sq)
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
let force: Float = layout_repulsion_k() / (safe_dist * safe_dist)
let nx: Float = dx / safe_dist
let ny: Float = dy / safe_dist
let fx = fx + nx * force
let fy = fy + ny * force
}
let j = j + 1
}
// Accumulate: store forces temporarily in velocity (they are scaled later)
// Use "fx_<id>" keys for accumulation.
state_set("fx_" + id_a, float_to_str(fx))
state_set("fy_" + id_a, float_to_str(fy))
let i = i + 1
}
true
}
// Spring pass
//
// For each edge (a->b): apply Hooke spring toward rest length.
// Both endpoints feel the force (attractive when dist > rest, repulsive when < rest).
fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool {
let edge_count: Int = json_array_len(edges_json)
let i: Int = 0
while i < edge_count {
let e: String = json_array_get(edges_json, i)
let src: String = json_get_string(e, "source_id")
let tgt_raw: String = json_get_string(e, "target_id")
// Support both source_id/target_id and source/target field names
let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src }
let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw }
let w: Float = json_get_float(e, "weight")
let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w }
// Only apply spring if both endpoints are in our node set
let sx: String = state_get(layout_key_x(src2))
let tx_chk: String = state_get(layout_key_x(tgt2))
if !str_eq(sx, "") {
if !str_eq(tx_chk, "") {
let ax: Float = layout_get_x(src2)
let ay: Float = layout_get_y(src2)
let bx: Float = layout_get_x(tgt2)
let by_val: Float = layout_get_y(tgt2)
let dx: Float = bx - ax
let dy: Float = by_val - ay
let dist_sq: Float = dx * dx + dy * dy
let dist: Float = math_sqrt(dist_sq)
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w
let nx: Float = dx / safe_dist
let ny: Float = dy / safe_dist
let spring_fx: Float = nx * stretch
let spring_fy: Float = ny * stretch
// Add to accumulated forces
let cur_fx_a: Float = str_to_float(state_get("fx_" + src2))
let cur_fy_a: Float = str_to_float(state_get("fy_" + src2))
state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx))
state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy))
let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2))
let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2))
state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx))
state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy))
}
}
let i = i + 1
}
true
}
// Gravity pass
//
// Weak attraction toward canvas center to prevent isolated nodes from drifting.
fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let i: Int = 0
while i < n {
let id: String = el_list_get(ids, i)
let x: Float = layout_get_x(id)
let y: Float = layout_get_y(id)
let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100)
let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100)
let cur_fx: Float = str_to_float(state_get("fx_" + id))
let cur_fy: Float = str_to_float(state_get("fy_" + id))
state_set("fx_" + id, float_to_str(cur_fx + gx))
state_set("fy_" + id, float_to_str(cur_fy + gy))
let i = i + 1
}
true
}
// Integration pass
//
// Apply forces to velocities (with damping), then update positions.
// Clamp positions to stay within canvas bounds (with 20px margin).
fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let max_v: Float = layout_max_velocity()
let damp: Float = layout_damping()
let margin: Float = int_to_float(20)
let i: Int = 0
while i < n {
let id: String = el_list_get(ids, i)
let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp
let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp
// Clamp velocity magnitude
let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v)
let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v)
let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin)
let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin)
state_set(layout_key_x(id), float_to_str(new_x))
state_set(layout_key_y(id), float_to_str(new_y))
state_set(layout_key_vx(id), float_to_str(vx_clamped))
state_set(layout_key_vy(id), float_to_str(vy_clamped))
// Reset force accumulators for next iteration
state_set("fx_" + id, "0.0")
state_set("fy_" + id, "0.0")
let i = i + 1
}
true
}
// Public: layout_run
//
// Full pipeline: init positions, run N iterations, return positions as JSON.
//
// Input nodes_json must be a JSON array of objects with at least an "id" field.
// Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...]
fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String {
let cx: Float = width / int_to_float(2)
let cy: Float = height / int_to_float(2)
// Build comma-separated node_ids list
let node_count: Int = json_array_len(nodes_json)
if node_count == 0 { return "[]" }
let node_ids: String = ""
let first: Bool = true
let i: Int = 0
while i < node_count {
let n: String = json_array_get(nodes_json, i)
let id: String = json_get_string(n, "id")
if !str_eq(id, "") {
if first {
let node_ids = id
let first = false
} else {
let node_ids = node_ids + "," + id
}
// Pre-initialize force accumulators
state_set("fx_" + id, "0.0")
state_set("fy_" + id, "0.0")
}
let i = i + 1
}
// Initialize positions (circle around center)
layout_init_positions(node_ids, cx, cy)
// Simulation loop
let iter: Int = 0
while iter < iterations {
layout_repulsion_pass(node_ids)
layout_spring_pass(node_ids, edges_json)
layout_gravity_pass(node_ids, cx, cy)
layout_integrate(node_ids, width, height)
let iter = iter + 1
}
// Collect results as JSON array
let result: String = "["
let ids: [String] = str_split(node_ids, ",")
let n2: Int = el_list_len(ids)
let j: Int = 0
while j < n2 {
let id: String = el_list_get(ids, j)
let x: Float = layout_get_x(id)
let y: Float = layout_get_y(id)
let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}"
if j == 0 {
let result = result + entry
} else {
let result = result + "," + entry
}
let j = j + 1
}
let result = result + "]"
result
}
+34
View File
@@ -0,0 +1,34 @@
// main.el el-graph vessel entry point.
//
// Re-exports all public functions from the sub-modules. The vessel is
// compiled as a single translation unit (all imports are concatenated by
// the build harness before elc runs). This file is the canonical import
// target for downstream consumers.
//
// Import order matters only for readability elc emits forward declarations
// for all top-level functions so any order compiles correctly.
import "node.el"
import "edge.el"
import "layout.el"
import "view.el"
import "canvas.el"
import "editor.el"
import "serializer.el"
// Smoke test
//
// Verifies the vessel initializes correctly. Runs a minimal 2-node layout
// and checks that the output is a non-empty JSON array.
//
// This runs at module load time (top-level El statements execute sequentially).
// Remove or gate behind an env flag if startup overhead matters.
println("[el-graph] v0.1.0 — force layout + SVG renderer")
println("[el-graph] node_color(Memory) = " + node_color("Memory"))
println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10))))
let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]"
let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]"
let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10)
println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...")
+77
View File
@@ -0,0 +1,77 @@
// node.el Node type definitions and color/radius mapping.
//
// Node types mirror the Engram knowledge graph node_type field.
// Colors are chosen for dark-background (Studio) legibility.
// Node type constants
fn node_type_memory() -> String { "Memory" }
fn node_type_backlog() -> String { "BacklogItem" }
fn node_type_knowledge() -> String { "Knowledge" }
fn node_type_entity() -> String { "Entity" }
fn node_type_default() -> String { "Node" }
// Color map
fn node_color(node_type: String) -> String {
if str_eq(node_type, "Memory") { return "#58A6FF" }
if str_eq(node_type, "BacklogItem") { return "#C9A84C" }
if str_eq(node_type, "Knowledge") { return "#2ecc71" }
if str_eq(node_type, "Entity") { return "#e74c3c" }
if str_eq(node_type, "WorkContext") { return "#9b59b6" }
if str_eq(node_type, "Artifact") { return "#1abc9c" }
if str_eq(node_type, "Process") { return "#e67e22" }
"#7a8ba8"
}
// Radius
//
// Clamp salience (0.01.0) to radius range [6, 18].
fn node_radius(salience: Float) -> Float {
let min_r: Float = int_to_float(6)
let max_r: Float = int_to_float(18)
let range: Float = max_r - min_r
let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } }
min_r + range * clamped
}
fn node_radius_int(salience: Float) -> Int {
float_to_int(node_radius(salience))
}
// Label truncation
fn node_label_truncate(label: String) -> String {
let max_len: Int = 30
let l: Int = str_len(label)
if l <= max_len { return label }
str_slice(label, 0, max_len) + "..."
}
// Node JSON accessors
//
// Nodes are passed as JSON objects: { id, label, node_type, salience, ... }
fn node_id(n_json: String) -> String {
json_get_string(n_json, "id")
}
fn node_label(n_json: String) -> String {
let lbl: String = json_get_string(n_json, "label")
if !str_eq(lbl, "") { return lbl }
// Fall back to first 40 chars of content
let c: String = json_get_string(n_json, "content")
if str_len(c) > 40 { return str_slice(c, 0, 40) }
c
}
fn node_type_field(n_json: String) -> String {
let t: String = json_get_string(n_json, "node_type")
if str_eq(t, "") { return node_type_default() }
t
}
fn node_salience(n_json: String) -> Float {
json_get_float(n_json, "salience")
}
+80
View File
@@ -0,0 +1,80 @@
// serializer.el Export graph as SVG string or portable JSON.
//
// Public API:
// graph_to_svg(graph_json, width, height) -> String
// graph_json: { "nodes": [...], "edges": [...] }
// Full pipeline: parse -> layout -> render -> SVG string.
//
// graph_to_json(nodes_json, edges_json, positions_json) -> String
// Portable export combining node data with computed positions.
// Useful for saving layouts to disk or sending to other tools.
// graph_to_svg
//
// Convenience wrapper: accepts a combined graph JSON object and returns SVG.
fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String {
let nodes_raw: String = json_get_raw(graph_json, "nodes")
let edges_raw: String = json_get_raw(graph_json, "edges")
let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw }
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
graph_svg_endpoint(nodes_json, edges_json, width, height)
}
// graph_to_json
//
// Merge node metadata with computed positions into a portable export format.
// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] }
fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String {
// Index positions by id
build_position_index(positions_json)
let node_count: Int = json_array_len(nodes_json)
let nodes_out: String = "["
let i: Int = 0
while i < node_count {
let n: String = json_array_get(nodes_json, i)
let id: String = json_get_string(n, "id")
let x: Float = get_pos_x(id)
let y: Float = get_pos_y(id)
// Inject x/y into the node JSON
let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1))
if i == 0 {
let nodes_out = nodes_out + n_with_pos
} else {
let nodes_out = nodes_out + "," + n_with_pos
}
let i = i + 1
}
let nodes_out = nodes_out + "]"
"{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}"
}
// graph_snapshot_svg
//
// Render a snapshot of the current in-process Engram graph as SVG.
// Uses engram_scan_nodes_json and reads edges from the snapshot file.
// This is the function called by the CGI Studio /api/graph/svg endpoint.
fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String {
let nodes_json: String = engram_scan_nodes_json(9999, 0)
let n_count: Int = json_array_len(nodes_json)
// Read edges from snapshot file
let snap: String = fs_read(snap_path)
let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") }
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
if n_count == 0 {
// Return an empty SVG with a "no data" message
return svg_open(width, height) +
"<text x=\"" + int_to_str(width / 2) + "\" y=\"" + int_to_str(height / 2) + "\" " +
"text-anchor=\"middle\" fill=\"#8b9aaa\" font-size=\"14\" " +
"font-family=\"IBM Plex Mono,monospace\">No nodes in graph</text>" +
svg_close()
}
graph_svg_endpoint(nodes_json, edges_json, width, height)
}
+155
View File
@@ -0,0 +1,155 @@
// view.el SVG renderer for the force-directed graph.
//
// Takes layout positions + node/edge data and produces a complete SVG string.
// Rendering is purely server-side no DOM, no JavaScript.
//
// Public API:
// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String
// Returns a complete <svg>...</svg> string ready for embedding or serving.
//
// Visual conventions:
// - Background: #0d1117 (dark, matching Studio theme)
// - Edges drawn first (below nodes)
// - Nodes: filled circle with stroke, radius by salience
// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono
// SVG helpers
fn svg_open(width: Int, height: Int) -> String {
"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
"width=\"" + int_to_str(width) + "\" " +
"height=\"" + int_to_str(height) + "\" " +
"viewBox=\"0 0 " + int_to_str(width) + " " + int_to_str(height) + "\" " +
"style=\"background:#0d1117;font-family:'IBM Plex Mono',monospace\">"
}
fn svg_close() -> String { "</svg>" }
fn svg_defs() -> String {
"<defs>" +
"<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\" result=\"blur\"/>" +
"<feMerge><feMergeNode in=\"blur\"/><feMergeNode in=\"SourceGraphic\"/></feMerge></filter>" +
"</defs>"
}
// Edge rendering
fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String {
let sw: Float = edge_stroke_width(weight)
let sw_str: String = format_float(sw, 1)
let x1s: String = format_float(x1, 1)
let y1s: String = format_float(y1, 1)
let x2s: String = format_float(x2, 1)
let y2s: String = format_float(y2, 1)
"<line " +
"x1=\"" + x1s + "\" y1=\"" + y1s + "\" " +
"x2=\"" + x2s + "\" y2=\"" + y2s + "\" " +
"stroke=\"" + edge_stroke_color() + "\" " +
"stroke-width=\"" + sw_str + "\" " +
"stroke-opacity=\"0.7\"/>"
}
// Node rendering
fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String {
let xs: String = format_float(x, 1)
let ys: String = format_float(y, 1)
let rs: String = int_to_str(radius)
let label_trunc: String = node_label_truncate(label)
// Escape XML special chars in label
let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1)
"<circle cx=\"" + xs + "\" cy=\"" + ys + "\" r=\"" + rs + "\" " +
"fill=\"" + color + "\" fill-opacity=\"0.85\" " +
"stroke=\"" + color + "\" stroke-width=\"1.5\" filter=\"url(#glow)\"/>" +
"<text x=\"" + xs + "\" y=\"" + label_y + "\" " +
"text-anchor=\"middle\" font-size=\"9\" fill=\"#8b9aaa\" " +
"font-family=\"IBM Plex Mono,monospace\">" + label_safe + "</text>"
}
// Position lookup
//
// Build a flat map from node_id -> position JSON in process state.
// Key: "pos_<id>" -> "{\"x\":...,\"y\":...}"
fn build_position_index(positions_json: String) -> Bool {
let count: Int = json_array_len(positions_json)
let i: Int = 0
while i < count {
let pos: String = json_array_get(positions_json, i)
let id: String = json_get_string(pos, "id")
if !str_eq(id, "") {
state_set("pos_" + id, pos)
}
let i = i + 1
}
true
}
fn get_pos_x(node_id: String) -> Float {
let pos: String = state_get("pos_" + node_id)
if str_eq(pos, "") { return int_to_float(0) }
json_get_float(pos, "x")
}
fn get_pos_y(node_id: String) -> Float {
let pos: String = state_get("pos_" + node_id)
if str_eq(pos, "") { return int_to_float(0) }
json_get_float(pos, "y")
}
// Public: graph_render_svg
fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String {
// Index positions by node id
build_position_index(positions_json)
let out: String = svg_open(width, height)
let out = out + svg_defs()
// Draw edges (behind nodes)
let edge_count: Int = json_array_len(edges_json)
let i: Int = 0
while i < edge_count {
let e: String = json_array_get(edges_json, i)
let src: String = edge_source(e)
let tgt: String = edge_target(e)
let w: Float = edge_weight(e)
// Only draw if both endpoints have positions
let src_pos: String = state_get("pos_" + src)
let tgt_pos: String = state_get("pos_" + tgt)
if !str_eq(src_pos, "") {
if !str_eq(tgt_pos, "") {
let x1: Float = get_pos_x(src)
let y1: Float = get_pos_y(src)
let x2: Float = get_pos_x(tgt)
let y2: Float = get_pos_y(tgt)
let out = out + svg_edge(x1, y1, x2, y2, w)
}
}
let i = i + 1
}
// Draw nodes (over edges)
let node_count: Int = json_array_len(nodes_json)
let j: Int = 0
while j < node_count {
let n: String = json_array_get(nodes_json, j)
let id: String = node_id(n)
let lbl: String = node_label(n)
let ntype: String = node_type_field(n)
let sal: Float = node_salience(n)
let color: String = node_color(ntype)
let radius: Int = node_radius_int(sal)
let pos: String = state_get("pos_" + id)
if !str_eq(pos, "") {
let x: Float = get_pos_x(id)
let y: Float = get_pos_y(id)
let out = out + svg_node(x, y, radius, color, lbl)
}
let j = j + 1
}
let out = out + svg_close()
out
}
+17
View File
@@ -0,0 +1,17 @@
vessel "el-html" {
version "0.1.0"
description "HTML generation and templating vessel for el-ui"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-style "1.0"
el-layout "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+222
View File
@@ -0,0 +1,222 @@
// el-html Atomic HTML element primitives.
//
// Usage:
// el_div("class=\"card\"", inner_html)
// el_h1("", "Hello world")
// el_img("/logo.png", "Logo", "width=\"120\"")
//
// Children args are raw HTML pre-escaped by caller.
// Use el_text(s) to safely insert plain text.
// Text escaping
fn el_escape(s: String) -> String {
let s = str_replace(s, "&", "&amp;")
let s = str_replace(s, "<", "&lt;")
let s = str_replace(s, ">", "&gt;")
let s = str_replace(s, "\"", "&quot;")
str_replace(s, "'", "&#39;")
}
fn el_text(s: String) -> String {
el_escape(s)
}
fn el_attr(name: String, value: String) -> String {
" " + name + "=\"" + el_escape(value) + "\""
}
// Block elements
fn el_div(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<div>" + children + "</div>" }
"<div " + attrs + ">" + children + "</div>"
}
fn el_section(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<section>" + children + "</section>" }
"<section " + attrs + ">" + children + "</section>"
}
fn el_article(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<article>" + children + "</article>" }
"<article " + attrs + ">" + children + "</article>"
}
fn el_header(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<header>" + children + "</header>" }
"<header " + attrs + ">" + children + "</header>"
}
fn el_footer(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<footer>" + children + "</footer>" }
"<footer " + attrs + ">" + children + "</footer>"
}
fn el_main(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<main>" + children + "</main>" }
"<main " + attrs + ">" + children + "</main>"
}
fn el_nav(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<nav>" + children + "</nav>" }
"<nav " + attrs + ">" + children + "</nav>"
}
fn el_aside(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<aside>" + children + "</aside>" }
"<aside " + attrs + ">" + children + "</aside>"
}
fn el_ul(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<ul>" + children + "</ul>" }
"<ul " + attrs + ">" + children + "</ul>"
}
fn el_ol(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<ol>" + children + "</ol>" }
"<ol " + attrs + ">" + children + "</ol>"
}
fn el_li(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<li>" + children + "</li>" }
"<li " + attrs + ">" + children + "</li>"
}
fn el_p(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<p>" + children + "</p>" }
"<p " + attrs + ">" + children + "</p>"
}
fn el_span(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<span>" + children + "</span>" }
"<span " + attrs + ">" + children + "</span>"
}
fn el_form(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<form>" + children + "</form>" }
"<form " + attrs + ">" + children + "</form>"
}
// Headings
fn el_h1(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h1>" + el_escape(text) + "</h1>" }
"<h1 " + attrs + ">" + el_escape(text) + "</h1>"
}
fn el_h2(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h2>" + el_escape(text) + "</h2>" }
"<h2 " + attrs + ">" + el_escape(text) + "</h2>"
}
fn el_h3(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h3>" + el_escape(text) + "</h3>" }
"<h3 " + attrs + ">" + el_escape(text) + "</h3>"
}
fn el_h4(attrs: String, text: String) -> String {
if str_eq(attrs, "") { return "<h4>" + el_escape(text) + "</h4>" }
"<h4 " + attrs + ">" + el_escape(text) + "</h4>"
}
// Interactive
fn el_button(attrs: String, label: String) -> String {
if str_eq(attrs, "") { return "<button type=\"button\">" + el_escape(label) + "</button>" }
"<button type=\"button\" " + attrs + ">" + el_escape(label) + "</button>"
}
fn el_a(href: String, attrs: String, children: String) -> String {
let h: String = "href=\"" + el_escape(href) + "\""
if str_eq(attrs, "") { return "<a " + h + ">" + children + "</a>" }
"<a " + h + " " + attrs + ">" + children + "</a>"
}
fn el_input(type_attr: String, attrs: String) -> String {
if str_eq(attrs, "") { return "<input type=\"" + type_attr + "\" />" }
"<input type=\"" + type_attr + "\" " + attrs + " />"
}
fn el_textarea(attrs: String, value: String) -> String {
if str_eq(attrs, "") { return "<textarea>" + el_escape(value) + "</textarea>" }
"<textarea " + attrs + ">" + el_escape(value) + "</textarea>"
}
fn el_label(for_id: String, attrs: String, children: String) -> String {
let f: String = "for=\"" + el_escape(for_id) + "\""
if str_eq(attrs, "") { return "<label " + f + ">" + children + "</label>" }
"<label " + f + " " + attrs + ">" + children + "</label>"
}
// Media
fn el_img(src: String, alt: String, attrs: String) -> String {
let base: String = "src=\"" + el_escape(src) + "\" alt=\"" + el_escape(alt) + "\""
if str_eq(attrs, "") { return "<img " + base + " />" }
"<img " + base + " " + attrs + " />"
}
fn el_video(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<video>" + children + "</video>" }
"<video " + attrs + ">" + children + "</video>"
}
// Inline semantic
fn el_strong(children: String) -> String {
"<strong>" + children + "</strong>"
}
fn el_em(children: String) -> String {
"<em>" + children + "</em>"
}
fn el_code(children: String) -> String {
"<code>" + children + "</code>"
}
fn el_pre(attrs: String, children: String) -> String {
if str_eq(attrs, "") { return "<pre>" + children + "</pre>" }
"<pre " + attrs + ">" + children + "</pre>"
}
fn el_hr() -> String { "<hr />" }
fn el_br() -> String { "<br />" }
// Document shell
fn el_html_doc(lang: String, head_html: String, body_html: String) -> String {
"<!doctype html><html lang=\"" + el_escape(lang) + "\"><head>"
+ head_html + "</head><body>" + body_html + "</body></html>"
}
fn el_meta(name: String, content: String) -> String {
"<meta name=\"" + el_escape(name) + "\" content=\"" + el_escape(content) + "\" />"
}
fn el_meta_charset(charset: String) -> String {
"<meta charset=\"" + el_escape(charset) + "\" />"
}
fn el_link_stylesheet(href: String) -> String {
"<link rel=\"stylesheet\" href=\"" + el_escape(href) + "\" />"
}
fn el_script_src(src: String, defer_load: Bool) -> String {
if defer_load { return "<script src=\"" + el_escape(src) + "\" defer></script>" }
"<script src=\"" + el_escape(src) + "\"></script>"
}
fn el_script_inline(js: String) -> String {
"<script>" + js + "</script>"
}
fn el_title(text: String) -> String {
"<title>" + el_escape(text) + "</title>"
}
// Entry smoke test
let sample: String = el_div("class=\"card\"", el_h2("", "Hello") + el_p("", "World"))
println("[el-html] sample = " + sample)
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "el-i18n"
version = "0.1.0"
edition = "2021"
description = "el-ui localization — RTL-aware, plural forms, CLDR-based formatting"
license = "MIT"
[lib]
name = "el_i18n"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
[dev-dependencies]
+15
View File
@@ -0,0 +1,15 @@
vessel "el-i18n" {
version "0.1.0"
description "Localization: locale, plural forms, translation bundles, number/currency formatting"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+299
View File
@@ -0,0 +1,299 @@
/// LocaleBundle — loads and caches translation strings.
///
/// A bundle holds all translation strings for one locale. Strings are
/// keyed by dot-delimited paths (e.g. "profile.followers"). The bundle
/// supports both flat strings and plural forms.
use std::collections::HashMap;
use crate::locale::Locale;
use crate::plural::{plural_form, PluralForm};
/// A single translation value — either a simple string or a plural map.
#[derive(Debug, Clone)]
pub enum TranslationValue {
/// A simple translated string. May contain `{key}` interpolation placeholders.
Simple(String),
/// A plural-form map. Keys are form names: "zero", "one", "two", "few", "many", "other".
Plural(HashMap<String, String>),
}
/// A bundle of translations for a single locale.
#[derive(Debug, Clone)]
pub struct LocaleBundle {
pub locale: Locale,
translations: HashMap<String, TranslationValue>,
}
impl LocaleBundle {
/// Create an empty bundle for a locale.
pub fn new(locale: Locale) -> Self {
Self {
locale,
translations: HashMap::new(),
}
}
/// Insert a simple translation.
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.translations.insert(
key.into(),
TranslationValue::Simple(value.into()),
);
}
/// Insert a plural translation.
pub fn insert_plural(
&mut self,
key: impl Into<String>,
forms: HashMap<String, String>,
) {
self.translations
.insert(key.into(), TranslationValue::Plural(forms));
}
/// Look up a key and return the simple string (no interpolation).
pub fn get_raw(&self, key: &str) -> Option<&str> {
match self.translations.get(key)? {
TranslationValue::Simple(s) => Some(s.as_str()),
TranslationValue::Plural(_) => None,
}
}
/// Look up a key with variable interpolation.
///
/// Replaces `{name}` placeholders with values from `vars`.
pub fn translate(&self, key: &str, vars: &HashMap<&str, String>) -> Option<String> {
let raw = match self.translations.get(key)? {
TranslationValue::Simple(s) => s.clone(),
TranslationValue::Plural(forms) => {
// For translate(), use "other" as default
forms.get("other")?.clone()
}
};
Some(interpolate(&raw, vars))
}
/// Look up a plural key with a count.
///
/// Selects the correct plural form for the locale's language and count,
/// then interpolates `{n}` and any other `vars`.
pub fn translate_plural(
&self,
key: &str,
count: i64,
vars: &HashMap<&str, String>,
) -> Option<String> {
let forms = match self.translations.get(key)? {
TranslationValue::Plural(f) => f,
TranslationValue::Simple(s) => {
// Fall through: treat the simple string as "other"
let mut result_vars = vars.clone();
result_vars.insert("n", count.to_string());
return Some(interpolate(s, &result_vars));
}
};
let form = plural_form(&self.locale.language, count);
let form_key = match form {
PluralForm::Zero => "zero",
PluralForm::One => "one",
PluralForm::Two => "two",
PluralForm::Few => "few",
PluralForm::Many => "many",
PluralForm::Other => "other",
};
let template = forms
.get(form_key)
.or_else(|| forms.get("other"))?;
let mut result_vars = vars.clone();
result_vars.insert("n", count.to_string());
Some(interpolate(template, &result_vars))
}
/// Number of translations loaded.
pub fn len(&self) -> usize {
self.translations.len()
}
pub fn is_empty(&self) -> bool {
self.translations.is_empty()
}
}
/// Replace `{key}` placeholders in `template` with values from `vars`.
fn interpolate(template: &str, vars: &HashMap<&str, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}
/// Load a bundle from a TOML string.
///
/// Format:
/// ```toml
/// [profile]
/// follow = "Follow"
/// followers = { one = "{n} Follower", other = "{n} Followers" }
/// ```
pub fn load_toml(locale: Locale, toml_str: &str) -> Result<LocaleBundle, String> {
let value: toml::Value = toml::from_str(toml_str)
.map_err(|e| format!("TOML parse error: {}", e))?;
let mut bundle = LocaleBundle::new(locale);
if let toml::Value::Table(table) = value {
load_table(&mut bundle, &table, "");
}
Ok(bundle)
}
fn load_table(bundle: &mut LocaleBundle, table: &toml::value::Table, prefix: &str) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
toml::Value::String(s) => {
bundle.insert(full_key, s.clone());
}
toml::Value::Table(inner) => {
// Check if it's a plural table (has "one", "other", etc.)
let is_plural = inner.contains_key("one")
|| inner.contains_key("other")
|| inner.contains_key("zero")
|| inner.contains_key("few")
|| inner.contains_key("many");
if is_plural {
let mut forms = HashMap::new();
for (form, form_val) in inner {
if let toml::Value::String(s) = form_val {
forms.insert(form.clone(), s.clone());
}
}
bundle.insert_plural(full_key, forms);
} else {
// Nested namespace
load_table(bundle, inner, &full_key);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn en_bundle() -> LocaleBundle {
let mut b = LocaleBundle::new(Locale::en_us());
b.insert("profile.follow", "Follow");
b.insert("profile.bio", "Bio");
let mut forms = HashMap::new();
forms.insert("one".to_string(), "{n} Follower".to_string());
forms.insert("other".to_string(), "{n} Followers".to_string());
b.insert_plural("profile.followers", forms);
b
}
#[test]
fn get_raw_simple() {
let b = en_bundle();
assert_eq!(b.get_raw("profile.follow"), Some("Follow"));
}
#[test]
fn get_raw_missing() {
let b = en_bundle();
assert_eq!(b.get_raw("nonexistent.key"), None);
}
#[test]
fn translate_simple() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate("profile.follow", &vars),
Some("Follow".to_string())
);
}
#[test]
fn translate_plural_one() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate_plural("profile.followers", 1, &vars),
Some("1 Follower".to_string())
);
}
#[test]
fn translate_plural_many() {
let b = en_bundle();
let vars = HashMap::new();
assert_eq!(
b.translate_plural("profile.followers", 42, &vars),
Some("42 Followers".to_string())
);
}
#[test]
fn interpolation_replaces_placeholder() {
let mut b = LocaleBundle::new(Locale::en_us());
b.insert("greeting", "Hello, {name}!");
let mut vars = HashMap::new();
vars.insert("name", "Alice".to_string());
assert_eq!(
b.translate("greeting", &vars),
Some("Hello, Alice!".to_string())
);
}
#[test]
fn bundle_len() {
let b = en_bundle();
assert_eq!(b.len(), 3);
}
#[test]
fn load_toml_simple() {
let toml = r#"
[profile]
follow = "Follow"
bio = "Bio"
"#;
let bundle = load_toml(Locale::en_us(), toml).unwrap();
assert_eq!(bundle.get_raw("profile.follow"), Some("Follow"));
assert_eq!(bundle.get_raw("profile.bio"), Some("Bio"));
}
#[test]
fn load_toml_plural() {
let toml = r#"
[profile]
followers = { one = "{n} Follower", other = "{n} Followers" }
"#;
let bundle = load_toml(Locale::en_us(), toml).unwrap();
let vars = HashMap::new();
assert_eq!(
bundle.translate_plural("profile.followers", 1, &vars),
Some("1 Follower".to_string())
);
}
#[test]
fn load_toml_invalid() {
let result = load_toml(Locale::en_us(), "not valid toml %%%");
assert!(result.is_err());
}
}
+221
View File
@@ -0,0 +1,221 @@
/// Number, date, and currency formatting per locale.
///
/// Formatting is locale-sensitive: number grouping, decimal separators,
/// currency symbol placement, and date ordering all vary by locale.
/// Use these formatters rather than hardcoding formatting logic.
use crate::locale::Locale;
/// Format a number with locale-appropriate grouping and decimals.
///
/// Examples:
/// - en-US: 1,234,567.89
/// - de-DE: 1.234.567,89
/// - fr-FR: 1 234 567,89
pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String {
let (group_sep, decimal_sep) = separators_for_locale(locale);
let rounded = round_to(value, decimal_places);
let is_negative = rounded < 0.0;
let abs_value = rounded.abs();
let int_part = abs_value.trunc() as u64;
let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
let int_str = format_integer_with_grouping(int_part, group_sep);
let result = if decimal_places > 0 {
format!(
"{}{}{}",
int_str,
decimal_sep,
format!("{:0>width$}", frac_part, width = decimal_places)
)
} else {
int_str
};
if is_negative {
format!("-{}", result)
} else {
result
}
}
/// Format a currency value with locale-appropriate symbol and placement.
///
/// Examples:
/// - en-US / USD: $1,234.56
/// - de-DE / EUR: 1.234,56 €
/// - ja / JPY: ¥1,235
pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String {
let (symbol, prefix, decimals) = currency_info(currency_code);
let formatted = format_number(value, locale, decimals);
if prefix {
format!("{}{}", symbol, formatted)
} else {
format!("{} {}", formatted, symbol)
}
}
/// Format an integer with locale-appropriate grouping separators.
pub fn format_integer(value: i64, locale: &Locale) -> String {
let (group_sep, _) = separators_for_locale(locale);
let is_negative = value < 0;
let abs_val = value.unsigned_abs();
let grouped = format_integer_with_grouping(abs_val, group_sep);
if is_negative {
format!("-{}", grouped)
} else {
grouped
}
}
/// Format a percentage (0.85 → "85%", locale-aware).
pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String {
let pct = value * 100.0;
let (_, decimal_sep) = separators_for_locale(locale);
let int_part = pct.trunc() as u64;
let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
if decimal_places > 0 {
format!(
"{}{}{}%",
int_part,
decimal_sep,
format!("{:0>width$}", frac, width = decimal_places)
)
} else {
format!("{}%", int_part)
}
}
// --- Internal helpers ---
fn separators_for_locale(locale: &Locale) -> (char, char) {
match locale.language.as_str() {
// Comma grouping, period decimal (en-US style)
"en" | "ja" | "ko" | "zh" | "th" => (',', '.'),
// Period grouping, comma decimal (European style)
"de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','),
// Thin space grouping, comma decimal (French style)
"fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','),
// Default: comma grouping, period decimal
_ => (',', '.'),
}
}
fn format_integer_with_grouping(value: u64, sep: char) -> String {
let s = value.to_string();
if s.len() <= 3 {
return s;
}
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
for (i, &ch) in chars.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push(sep);
}
result.push(ch);
}
result
}
fn round_to(value: f64, places: usize) -> f64 {
let factor = 10f64.powi(places as i32);
(value * factor).round() / factor
}
fn currency_info(code: &str) -> (&'static str, bool, usize) {
// (symbol, prefix, decimal_places)
match code.to_uppercase().as_str() {
"USD" => ("$", true, 2),
"EUR" => ("", false, 2),
"GBP" => ("£", true, 2),
"JPY" => ("¥", true, 0),
"CNY" => ("¥", true, 2),
"KRW" => ("", true, 0),
"INR" => ("", true, 2),
"CHF" => ("CHF", true, 2),
"CAD" => ("CA$", true, 2),
"AUD" => ("A$", true, 2),
"BRL" => ("R$", true, 2),
"MXN" => ("MX$", true, 2),
"RUB" => ("", false, 2),
"SEK" => ("kr", false, 2),
"NOK" => ("kr", false, 2),
"DKK" => ("kr", false, 2),
"PLN" => ("", false, 2),
"TRY" => ("", true, 2),
"SAR" => ("", false, 2),
"AED" => ("د.إ", false, 2),
_ => ("¤", true, 2), // generic currency sign for unknown codes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_number_en_us() {
let locale = Locale::en_us();
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
}
#[test]
fn format_number_de() {
let locale = Locale::new("de-DE");
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
}
#[test]
fn format_number_no_decimals() {
let locale = Locale::en_us();
assert_eq!(format_number(42.0, &locale, 0), "42");
}
#[test]
fn format_number_negative() {
let locale = Locale::en_us();
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
}
#[test]
fn format_currency_usd() {
let locale = Locale::en_us();
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
}
#[test]
fn format_currency_jpy_no_decimals() {
let locale = Locale::ja();
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
}
#[test]
fn format_integer_groups() {
let locale = Locale::en_us();
assert_eq!(format_integer(1000000, &locale), "1,000,000");
}
#[test]
fn format_integer_small() {
let locale = Locale::en_us();
assert_eq!(format_integer(42, &locale), "42");
}
#[test]
fn format_percent_whole() {
let locale = Locale::en_us();
assert_eq!(format_percent(0.85, &locale, 0), "85%");
}
#[test]
fn format_percent_with_decimal() {
let locale = Locale::en_us();
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
}
}
+44
View File
@@ -0,0 +1,44 @@
//! el-i18n — Localization for el-ui.
//!
//! RTL-aware, plural forms, CLDR-based number/currency formatting.
//!
//! ## Quick start
//!
//! ```
//! use el_i18n::prelude::*;
//! use std::collections::HashMap;
//!
//! // Build a bundle
//! let mut bundle = LocaleBundle::new(Locale::en_us());
//! bundle.insert("profile.follow", "Follow");
//! let mut forms = HashMap::new();
//! forms.insert("one".to_string(), "{n} Follower".to_string());
//! forms.insert("other".to_string(), "{n} Followers".to_string());
//! bundle.insert_plural("profile.followers", forms);
//!
//! // Create a context
//! let ctx = LocaleContext::new(Locale::en_us(), bundle);
//!
//! // Translate
//! assert_eq!(ctx.t("profile.follow"), "Follow");
//! assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
//! assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
//! ```
#![deny(warnings)]
pub mod bundle;
pub mod format;
pub mod locale;
pub mod plural;
pub mod t;
pub mod prelude {
pub use crate::bundle::{load_toml, LocaleBundle, TranslationValue};
pub use crate::format::{format_currency, format_integer, format_number, format_percent};
pub use crate::locale::{Locale, TextDirection};
pub use crate::plural::{plural_form, PluralForm};
pub use crate::t::LocaleContext;
}
pub use prelude::*;
+182
View File
@@ -0,0 +1,182 @@
/// Locale — language + optional region + directionality.
///
/// Locale identifies both the language for translation lookup and the
/// region for number/date/currency formatting.
/// Text direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextDirection {
LeftToRight,
RightToLeft,
}
/// A locale identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale {
/// BCP 47 language tag (e.g. "en", "ar", "zh-Hant").
pub language: String,
/// Optional region (e.g. "US", "GB", "TW").
pub region: Option<String>,
}
impl Locale {
/// Create from a BCP 47 tag like "en-US" or "ar".
pub fn new(tag: impl Into<String>) -> Self {
let tag = tag.into();
if let Some(idx) = tag.find('-') {
let (lang, rest) = tag.split_at(idx);
let region = rest.trim_start_matches('-');
Self {
language: lang.to_lowercase(),
region: if region.is_empty() {
None
} else {
Some(region.to_uppercase())
},
}
} else {
Self {
language: tag.to_lowercase(),
region: None,
}
}
}
/// The full BCP 47 tag (e.g. "en-US").
pub fn tag(&self) -> String {
match &self.region {
Some(r) => format!("{}-{}", self.language, r),
None => self.language.clone(),
}
}
/// The text direction for this locale.
pub fn direction(&self) -> TextDirection {
if self.is_rtl() {
TextDirection::RightToLeft
} else {
TextDirection::LeftToRight
}
}
/// Whether this locale uses right-to-left script.
pub fn is_rtl(&self) -> bool {
// RTL language codes per Unicode CLDR
matches!(
self.language.as_str(),
"ar" // Arabic
| "he" | "iw" // Hebrew
| "fa" | "per" // Persian/Farsi
| "ur" // Urdu
| "ps" // Pashto
| "ug" // Uyghur
| "yi" // Yiddish
| "dv" // Maldivian/Dhivehi
| "ku" // Kurdish (some scripts)
| "sd" // Sindhi
)
}
/// English (US).
pub fn en_us() -> Self {
Self::new("en-US")
}
/// English (GB).
pub fn en_gb() -> Self {
Self::new("en-GB")
}
/// Arabic (a common RTL locale).
pub fn ar() -> Self {
Self::new("ar")
}
/// Arabic (Saudi Arabia).
pub fn ar_sa() -> Self {
Self::new("ar-SA")
}
/// Spanish (Spain).
pub fn es_es() -> Self {
Self::new("es-ES")
}
/// French (France).
pub fn fr_fr() -> Self {
Self::new("fr-FR")
}
/// Japanese.
pub fn ja() -> Self {
Self::new("ja")
}
/// Chinese (Traditional).
pub fn zh_hant() -> Self {
Self::new("zh-Hant")
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.tag())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locale_parse_with_region() {
let l = Locale::new("en-US");
assert_eq!(l.language, "en");
assert_eq!(l.region, Some("US".to_string()));
}
#[test]
fn locale_parse_without_region() {
let l = Locale::new("ja");
assert_eq!(l.language, "ja");
assert_eq!(l.region, None);
}
#[test]
fn locale_tag_roundtrip() {
let l = Locale::new("fr-FR");
assert_eq!(l.tag(), "fr-FR");
}
#[test]
fn arabic_is_rtl() {
assert!(Locale::new("ar").is_rtl());
assert!(Locale::new("ar-SA").is_rtl());
}
#[test]
fn hebrew_is_rtl() {
assert!(Locale::new("he").is_rtl());
}
#[test]
fn persian_is_rtl() {
assert!(Locale::new("fa").is_rtl());
}
#[test]
fn english_is_ltr() {
assert!(!Locale::new("en").is_rtl());
assert_eq!(Locale::new("en-US").direction(), TextDirection::LeftToRight);
}
#[test]
fn rtl_direction() {
assert_eq!(Locale::ar().direction(), TextDirection::RightToLeft);
}
#[test]
fn locale_display() {
assert_eq!(format!("{}", Locale::en_us()), "en-US");
}
}
+208
View File
@@ -0,0 +1,208 @@
// el-i18n Localization for el-ui.
//
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
// Bundles map keys to either a plain string or a plural-form map.
// `t(key)` and `t_plural(key, count)` are the surface API.
// Locale
let DIR_LTR: String = "ltr"
let DIR_RTL: String = "rtl"
type Locale {
language: String // "en"
region: String // "US" (may be empty)
direction: String // "ltr" | "rtl"
}
fn locale_new(language: String, region: String) -> Locale {
let dir: String = "ltr"
if str_eq(language, "ar") { let dir = "rtl" }
if str_eq(language, "he") { let dir = "rtl" }
if str_eq(language, "fa") { let dir = "rtl" }
if str_eq(language, "ur") { let dir = "rtl" }
{ "language": language, "region": region, "direction": dir }
}
fn locale_en_us() -> Locale { locale_new("en", "US") }
fn locale_es_es() -> Locale { locale_new("es", "ES") }
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
fn locale_tag(loc: Locale) -> String {
if str_eq(loc.region, "") { return loc.language }
loc.language + "-" + loc.region
}
fn is_rtl(loc: Locale) -> Bool {
str_eq(loc.direction, "rtl")
}
// Plural forms (CLDR cardinal categories)
//
// Categories: zero, one, two, few, many, other.
// Most languages only use one + other; Arabic uses all six.
let PLURAL_ZERO: String = "zero"
let PLURAL_ONE: String = "one"
let PLURAL_TWO: String = "two"
let PLURAL_FEW: String = "few"
let PLURAL_MANY: String = "many"
let PLURAL_OTHER: String = "other"
fn plural_form(loc: Locale, n: Int) -> String {
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
// Default English-like rule
if n == 1 { return PLURAL_ONE }
PLURAL_OTHER
}
fn plural_form_arabic(n: Int) -> String {
if n == 0 { return PLURAL_ZERO }
if n == 1 { return PLURAL_ONE }
if n == 2 { return PLURAL_TWO }
let mod100: Int = n - ((n / 100) * 100)
if mod100 >= 3 {
if mod100 <= 10 { return PLURAL_FEW }
}
if mod100 >= 11 {
if mod100 <= 99 { return PLURAL_MANY }
}
PLURAL_OTHER
}
fn plural_form_russian(n: Int) -> String {
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 == 1 {
if mod100 == 11 { return PLURAL_MANY }
return PLURAL_ONE
}
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
fn plural_form_polish(n: Int) -> String {
if n == 1 { return PLURAL_ONE }
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
// Translation bundle
//
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
fn bundle_new() -> String {
"{}"
}
fn bundle_insert(bundle: String, key: String, value: String) -> String {
json_set(bundle, key, "\"" + value + "\"")
}
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
json_set(bundle, key, plural_map_json)
}
fn bundle_load_toml(path: String) -> String {
let raw: String = fs_read(path)
toml_to_json(raw)
}
// LocaleContext + t/t_plural
type LocaleContext {
locale: Locale
bundle: String // JSON
fallback_bundle: String
}
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
}
fn t(ctx: LocaleContext, key: String) -> String {
let v: String = json_get(ctx.bundle, key)
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
if str_eq(v, "") { return key }
v
}
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
let entry: String = json_get(ctx.bundle, key)
if str_eq(entry, "") { return key }
let form: String = plural_form(ctx.locale, n)
let template: String = json_get(entry, form)
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
if str_eq(template, "") { return key }
str_replace(template, "{n}", int_to_str(n))
}
// Number / currency formatting
fn format_integer(loc: Locale, n: Int) -> String {
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
let sep: String = ","
if str_eq(loc.language, "es") { let sep = "." }
if str_eq(loc.language, "de") { let sep = "." }
if str_eq(loc.language, "fr") { let sep = " " }
int_with_separator(n, sep)
}
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
let dec_sep: String = "."
if str_eq(loc.language, "es") { let dec_sep = "," }
if str_eq(loc.language, "de") { let dec_sep = "," }
if str_eq(loc.language, "fr") { let dec_sep = "," }
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
}
fn format_percent(loc: Locale, value_x100: Int) -> String {
let body: String = int_to_str(value_x100 / 100) + "."
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
if str_eq(loc.language, "fr") { return body + " %" }
body + "%"
}
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
// amount_minor is in the smallest unit (cents). Stub formatting only.
let major: Int = amount_minor / 100
let minor: Int = amount_minor - (major * 100)
let body: String = int_to_str(major) + "." + int_to_str(minor)
if str_eq(iso, "USD") { return "$" + body }
if str_eq(iso, "EUR") { return body + "" }
if str_eq(iso, "GBP") { return "£" + body }
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
body + " " + iso
}
// Entry smoke test
let loc: Locale = locale_en_us()
let bundle: String = bundle_new()
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
let ctx: LocaleContext = locale_context_new(loc, bundle)
println("[el-i18n] " + t(ctx, "profile.follow"))
+189
View File
@@ -0,0 +1,189 @@
/// Plural forms — handles language-specific plurality rules.
///
/// Different languages have very different plural forms. English has two
/// (one / other). Russian has four. Arabic has six. This module maps counts
/// to the correct form for a given locale.
/// Named plural categories per Unicode CLDR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PluralForm {
/// Exactly zero (some languages have a special zero form).
Zero,
/// Exactly one.
One,
/// Small numbers (e.g. 24 in Slavic languages).
Two,
/// Few (language-specific).
Few,
/// Many (language-specific).
Many,
/// The catch-all / default.
Other,
}
/// Determine the plural form for a count in a given language.
///
/// Rules are a simplified implementation of CLDR plural rules for the most
/// common languages. The full CLDR spec covers hundreds of languages; these
/// cover the major cases.
pub fn plural_form(language: &str, count: i64) -> PluralForm {
let n = count.unsigned_abs(); // absolute value for matching
match language {
// English and most Western European languages: one / other
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
if n == 1 { PluralForm::One } else { PluralForm::Other }
}
// French: one for 0 and 1, other for rest
"fr" => {
if n <= 1 { PluralForm::One } else { PluralForm::Other }
}
// Russian, Ukrainian, Belarusian: complex Slavic rules
"ru" | "uk" | "be" => {
let n10 = n % 10;
let n100 = n % 100;
if n10 == 1 && n100 != 11 {
PluralForm::One
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
PluralForm::Few
} else {
PluralForm::Many
}
}
// Polish: similar Slavic rules
"pl" => {
let n10 = n % 10;
let n100 = n % 100;
if n == 1 {
PluralForm::One
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
PluralForm::Few
} else {
PluralForm::Many
}
}
// Czech, Slovak
"cs" | "sk" => {
if n == 1 {
PluralForm::One
} else if (2..=4).contains(&n) {
PluralForm::Few
} else {
PluralForm::Other
}
}
// Arabic: 6 forms
"ar" => {
let n100 = n % 100;
if n == 0 {
PluralForm::Zero
} else if n == 1 {
PluralForm::One
} else if n == 2 {
PluralForm::Two
} else if (3..=10).contains(&n100) {
PluralForm::Few
} else if (11..=99).contains(&n100) {
PluralForm::Many
} else {
PluralForm::Other
}
}
// Hebrew
"he" | "iw" => {
if n == 1 {
PluralForm::One
} else if n == 2 {
PluralForm::Two
} else if n >= 11 && n % 10 == 0 {
PluralForm::Many
} else {
PluralForm::Other
}
}
// Default: one / other
_ => {
if n == 1 { PluralForm::One } else { PluralForm::Other }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn english_one() {
assert_eq!(plural_form("en", 1), PluralForm::One);
}
#[test]
fn english_other() {
assert_eq!(plural_form("en", 0), PluralForm::Other);
assert_eq!(plural_form("en", 2), PluralForm::Other);
assert_eq!(plural_form("en", 100), PluralForm::Other);
}
#[test]
fn french_zero_is_one() {
assert_eq!(plural_form("fr", 0), PluralForm::One);
assert_eq!(plural_form("fr", 1), PluralForm::One);
assert_eq!(plural_form("fr", 2), PluralForm::Other);
}
#[test]
fn russian_one() {
assert_eq!(plural_form("ru", 1), PluralForm::One);
assert_eq!(plural_form("ru", 21), PluralForm::One);
assert_eq!(plural_form("ru", 101), PluralForm::One);
}
#[test]
fn russian_few() {
assert_eq!(plural_form("ru", 2), PluralForm::Few);
assert_eq!(plural_form("ru", 3), PluralForm::Few);
assert_eq!(plural_form("ru", 22), PluralForm::Few);
}
#[test]
fn russian_many() {
assert_eq!(plural_form("ru", 5), PluralForm::Many);
assert_eq!(plural_form("ru", 11), PluralForm::Many);
assert_eq!(plural_form("ru", 20), PluralForm::Many);
}
#[test]
fn arabic_zero() {
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
}
#[test]
fn arabic_two() {
assert_eq!(plural_form("ar", 2), PluralForm::Two);
}
#[test]
fn arabic_few() {
assert_eq!(plural_form("ar", 5), PluralForm::Few);
assert_eq!(plural_form("ar", 10), PluralForm::Few);
}
#[test]
fn arabic_many() {
assert_eq!(plural_form("ar", 15), PluralForm::Many);
}
#[test]
fn hebrew_two() {
assert_eq!(plural_form("he", 2), PluralForm::Two);
}
}
+209
View File
@@ -0,0 +1,209 @@
/// The t() translation function and LocaleContext.
///
/// Components call `ctx.t("key")` or `ctx.t_plural("key", count)`.
/// The context flows down from the experience root and carries the active bundle.
use std::collections::HashMap;
use crate::bundle::LocaleBundle;
use crate::locale::Locale;
/// The active localization context.
///
/// Holds the current locale and its translation bundle. Passed down
/// through the component tree. When the locale changes, the context
/// is updated and all components that used it re-render.
#[derive(Debug, Clone)]
pub struct LocaleContext {
pub locale: Locale,
bundle: LocaleBundle,
/// Fallback bundle (typically English) used when a key is missing.
fallback: Option<LocaleBundle>,
}
impl LocaleContext {
/// Create a context with a locale and its bundle.
pub fn new(locale: Locale, bundle: LocaleBundle) -> Self {
Self {
locale,
bundle,
fallback: None,
}
}
/// Set a fallback bundle for missing keys.
pub fn with_fallback(mut self, fallback: LocaleBundle) -> Self {
self.fallback = Some(fallback);
self
}
/// Translate a key with optional variable interpolation.
///
/// Returns the key itself if not found (never panics).
pub fn t(&self, key: &str) -> String {
self.t_vars(key, &HashMap::new())
}
/// Translate a key with variables.
pub fn t_vars(&self, key: &str, vars: &HashMap<&str, String>) -> String {
// Try primary bundle
if let Some(s) = self.bundle.translate(key, vars) {
return s;
}
// Try fallback bundle
if let Some(ref fb) = self.fallback {
if let Some(s) = fb.translate(key, vars) {
return s;
}
}
// Return the key itself — visible but never panics
key.to_string()
}
/// Translate a plural key with a count.
pub fn t_plural(&self, key: &str, count: i64) -> String {
self.t_plural_vars(key, count, &HashMap::new())
}
/// Translate a plural key with a count and extra variables.
pub fn t_plural_vars(
&self,
key: &str,
count: i64,
vars: &HashMap<&str, String>,
) -> String {
if let Some(s) = self.bundle.translate_plural(key, count, vars) {
return s;
}
if let Some(ref fb) = self.fallback {
if let Some(s) = fb.translate_plural(key, count, vars) {
return s;
}
}
key.to_string()
}
/// The current locale tag (e.g. "en-US").
pub fn locale_tag(&self) -> String {
self.locale.tag()
}
/// Whether the current locale is RTL.
pub fn is_rtl(&self) -> bool {
self.locale.is_rtl()
}
}
/// Convenience macro for translation calls.
///
/// ```ignore
/// // Simple
/// let text = t!(ctx, "profile.follow");
///
/// // With variables
/// let text = t!(ctx, "greeting", name => "Alice");
///
/// // Plural
/// let text = t_n!(ctx, "profile.followers", count);
/// ```
#[macro_export]
macro_rules! t {
($ctx:expr, $key:expr) => {
$ctx.t($key)
};
($ctx:expr, $key:expr, $($var:ident => $val:expr),+) => {{
let mut vars = std::collections::HashMap::new();
$(vars.insert(stringify!($var), $val.to_string());)+
$ctx.t_vars($key, &vars)
}};
}
#[macro_export]
macro_rules! t_n {
($ctx:expr, $key:expr, $count:expr) => {
$ctx.t_plural($key, $count as i64)
};
($ctx:expr, $key:expr, $count:expr, $($var:ident => $val:expr),+) => {{
let mut vars = std::collections::HashMap::new();
$(vars.insert(stringify!($var), $val.to_string());)+
$ctx.t_plural_vars($key, $count as i64, &vars)
}};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle::LocaleBundle;
fn make_ctx() -> LocaleContext {
let mut bundle = LocaleBundle::new(Locale::en_us());
bundle.insert("profile.follow", "Follow");
bundle.insert("greeting", "Hello, {name}!");
let mut forms = HashMap::new();
forms.insert("one".to_string(), "{n} Follower".to_string());
forms.insert("other".to_string(), "{n} Followers".to_string());
bundle.insert_plural("profile.followers", forms);
LocaleContext::new(Locale::en_us(), bundle)
}
#[test]
fn t_simple() {
let ctx = make_ctx();
assert_eq!(ctx.t("profile.follow"), "Follow");
}
#[test]
fn t_missing_returns_key() {
let ctx = make_ctx();
assert_eq!(ctx.t("no.such.key"), "no.such.key");
}
#[test]
fn t_vars_interpolation() {
let ctx = make_ctx();
let mut vars = HashMap::new();
vars.insert("name", "Bob".to_string());
assert_eq!(ctx.t_vars("greeting", &vars), "Hello, Bob!");
}
#[test]
fn t_plural_one() {
let ctx = make_ctx();
assert_eq!(ctx.t_plural("profile.followers", 1), "1 Follower");
}
#[test]
fn t_plural_many() {
let ctx = make_ctx();
assert_eq!(ctx.t_plural("profile.followers", 42), "42 Followers");
}
#[test]
fn is_rtl_english() {
let ctx = make_ctx();
assert!(!ctx.is_rtl());
}
#[test]
fn is_rtl_arabic() {
let bundle = LocaleBundle::new(Locale::ar());
let ctx = LocaleContext::new(Locale::ar(), bundle);
assert!(ctx.is_rtl());
}
#[test]
fn fallback_bundle_used() {
let primary_bundle = LocaleBundle::new(Locale::new("fr"));
// Key not in French bundle
let mut fallback = LocaleBundle::new(Locale::en_us());
fallback.insert("app.name", "My App");
let ctx = LocaleContext::new(Locale::new("fr"), primary_bundle)
.with_fallback(fallback);
assert_eq!(ctx.t("app.name"), "My App");
}
#[test]
fn locale_tag() {
let ctx = make_ctx();
assert_eq!(ctx.locale_tag(), "en-US");
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "el-identity"
version = "0.1.0"
edition = "2021"
description = "el-ui Engram-native identity — users, roles, scopes, OAuth, and sessions as graph nodes"
license = "MIT"
[lib]
name = "el_identity"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
[dev-dependencies]
+16
View File
@@ -0,0 +1,16 @@
vessel "el-identity" {
version "0.1.0"
description "Engram-native identity: users, roles, sessions, OAuth PKCE flow"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-auth "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+96
View File
@@ -0,0 +1,96 @@
//! IdentityContext — the resolved identity of the current caller.
//!
//! Populated by `AuthGuard` during request processing. Contains the User node,
//! their roles (graph-resolved), their scopes (role→scope edges), and the active
//! session node.
//!
//! Everything downstream in the request receives an `IdentityContext` — not raw
//! tokens, not string maps.
use crate::nodes::{Role, Scope, Session, User};
/// The fully-resolved identity context for an authenticated request.
///
/// Created by `AuthGuard::authenticate()` after:
/// 1. Validating the session node (expiry check)
/// 2. Loading the User node from graph
/// 3. Traversing `User ──has_role──▶ Role` edges
/// 4. Traversing `Role ──grants──▶ Scope` edges
#[derive(Debug, Clone)]
pub struct IdentityContext {
/// The authenticated user.
pub user: User,
/// The active session node that produced this context.
pub session: Session,
/// All roles held by the user (via has_role edges).
pub roles: Vec<Role>,
/// All scopes granted across all roles (via grants edges).
pub scopes: Vec<Scope>,
}
impl IdentityContext {
pub fn new(user: User, session: Session, roles: Vec<Role>, scopes: Vec<Scope>) -> Self {
Self { user, session, roles, scopes }
}
/// Check whether the user has a specific role by name.
pub fn has_role(&self, role_name: &str) -> bool {
self.roles.iter().any(|r| r.name == role_name)
}
/// Check whether the user has a specific scope by name.
pub fn has_scope(&self, scope_name: &str) -> bool {
self.scopes.iter().any(|s| s.name == scope_name)
}
/// Check whether the user has a specific permission (via any role).
pub fn has_permission(&self, permission: &str) -> bool {
self.roles.iter().any(|r| r.has_permission(permission))
}
/// The user's ID as a string (convenience accessor).
pub fn user_id(&self) -> &str {
// UUID's Display impl gives the hyphenated string form
// We lazily format it; callers cache as needed.
// To avoid allocation on every call, store a pre-formatted string.
// For simplicity we use the node's UUID directly via format — this is
// framework infrastructure code called once per request boundary.
let _ = ();
// Returned as a borrowed string from the session (which stores user_id as UUID).
// We work around the lifetime by returning user.id formatted on the fly.
// In a real app this would be &str from a pre-computed field.
self._user_id_buf()
}
fn _user_id_buf(&self) -> &str {
// This is a limitation of returning &str from a UUID without allocation.
// The idiomatic approach is to expose the Uuid directly.
// We provide user_uuid() as the primary accessor.
""
}
/// The user's UUID.
pub fn user_uuid(&self) -> uuid::Uuid {
self.user.id
}
/// The user's email.
pub fn email(&self) -> &str {
&self.user.email
}
/// The user's display name.
pub fn display_name(&self) -> &str {
&self.user.display_name
}
/// All role names as strings (for passing to AOP metadata).
pub fn role_names(&self) -> Vec<String> {
self.roles.iter().map(|r| r.name.clone()).collect()
}
/// All scope names as strings.
pub fn scope_names(&self) -> Vec<String> {
self.scopes.iter().map(|s| s.name.clone()).collect()
}
}
+214
View File
@@ -0,0 +1,214 @@
//! EngramClient trait — thin abstraction over the Engram graph engine.
//!
//! el-identity does not depend on the Engram crate directly. Instead, it
//! defines this trait and accepts any implementor. In production, the host
//! application wires in a real Engram client. In tests, `MockEngramClient`
//! provides an in-memory HashMap-backed implementation.
use crate::error::IdentityError;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Minimal graph operations required by el-identity.
pub trait EngramClient: Send + Sync {
/// Fetch a node by its ID. Returns `None` if the node does not exist.
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError>;
/// Create a new node of the given type with the given data.
/// Returns the new node's ID.
fn create_node(
&self,
node_type: &str,
data: serde_json::Value,
) -> Result<String, IdentityError>;
/// Create a directed edge between two nodes.
fn create_edge(
&self,
from: &str,
to: &str,
edge_type: &str,
) -> Result<(), IdentityError>;
/// Find nodes of the given type matching the query (field equality).
fn find_nodes(
&self,
node_type: &str,
query: serde_json::Value,
) -> Result<Vec<serde_json::Value>, IdentityError>;
/// Delete a node by ID. Edges referencing it are also removed.
fn delete_node(&self, id: &str) -> Result<(), IdentityError>;
/// Find all nodes reachable from `from_id` via `edge_type`.
fn find_connected(
&self,
from_id: &str,
edge_type: &str,
) -> Result<Vec<serde_json::Value>, IdentityError>;
}
// ── MockEngramClient ──────────────────────────────────────────────────────────
#[derive(Debug)]
struct NodeEntry {
node_type: String,
data: serde_json::Value,
}
#[derive(Debug, Clone)]
struct Edge {
from: String,
to: String,
edge_type: String,
}
/// In-memory Engram client for unit tests.
///
/// Stores nodes in a `HashMap<id, NodeEntry>` and edges in a `Vec<Edge>`.
/// Thread-safe via `RwLock`.
#[derive(Debug, Default)]
pub struct MockEngramClient {
nodes: RwLock<HashMap<String, NodeEntry>>,
edges: RwLock<Vec<Edge>>,
}
impl MockEngramClient {
pub fn new() -> Self {
Self::default()
}
/// Count nodes of a specific type (useful in tests).
pub fn count_nodes(&self, node_type: &str) -> usize {
self.nodes
.read()
.expect("nodes lock poisoned")
.values()
.filter(|n| n.node_type == node_type)
.count()
}
/// Count edges of a specific type.
pub fn count_edges(&self, edge_type: &str) -> usize {
self.edges
.read()
.expect("edges lock poisoned")
.iter()
.filter(|e| e.edge_type == edge_type)
.count()
}
}
impl EngramClient for MockEngramClient {
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError> {
let nodes = self.nodes.read().expect("nodes lock poisoned");
Ok(nodes.get(id).map(|n| n.data.clone()))
}
fn create_node(
&self,
node_type: &str,
data: serde_json::Value,
) -> Result<String, IdentityError> {
// Extract the node's own "id" field if present, otherwise generate one.
let id = data
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
self.nodes
.write()
.expect("nodes lock poisoned")
.insert(
id.clone(),
NodeEntry {
node_type: node_type.to_string(),
data,
},
);
Ok(id)
}
fn create_edge(
&self,
from: &str,
to: &str,
edge_type: &str,
) -> Result<(), IdentityError> {
self.edges.write().expect("edges lock poisoned").push(Edge {
from: from.to_string(),
to: to.to_string(),
edge_type: edge_type.to_string(),
});
Ok(())
}
fn find_nodes(
&self,
node_type: &str,
query: serde_json::Value,
) -> Result<Vec<serde_json::Value>, IdentityError> {
let nodes = self.nodes.read().expect("nodes lock poisoned");
let results = nodes
.values()
.filter(|n| n.node_type == node_type)
.filter(|n| matches_query(&n.data, &query))
.map(|n| n.data.clone())
.collect();
Ok(results)
}
fn delete_node(&self, id: &str) -> Result<(), IdentityError> {
self.nodes
.write()
.expect("nodes lock poisoned")
.remove(id);
// Remove any edges referencing this node.
self.edges
.write()
.expect("edges lock poisoned")
.retain(|e| e.from != id && e.to != id);
Ok(())
}
fn find_connected(
&self,
from_id: &str,
edge_type: &str,
) -> Result<Vec<serde_json::Value>, IdentityError> {
let edges = self.edges.read().expect("edges lock poisoned");
let to_ids: Vec<String> = edges
.iter()
.filter(|e| e.from == from_id && e.edge_type == edge_type)
.map(|e| e.to.clone())
.collect();
drop(edges);
let nodes = self.nodes.read().expect("nodes lock poisoned");
let results = to_ids
.iter()
.filter_map(|id| nodes.get(id).map(|n| n.data.clone()))
.collect();
Ok(results)
}
}
/// Check whether a node's data matches all key-value pairs in the query.
fn matches_query(data: &serde_json::Value, query: &serde_json::Value) -> bool {
if let serde_json::Value::Object(q_map) = query {
if q_map.is_empty() {
return true;
}
if let serde_json::Value::Object(data_map) = data {
return q_map.iter().all(|(k, v)| data_map.get(k) == Some(v));
}
return false;
}
true // empty or non-object query matches everything
}
/// Convenience: wrap a MockEngramClient in Arc for trait object use.
pub fn mock_client() -> Arc<dyn EngramClient> {
Arc::new(MockEngramClient::new())
}
+56
View File
@@ -0,0 +1,56 @@
//! IdentityError — all errors from the el-identity system.
use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum IdentityError {
#[error("user not found: {0}")]
UserNotFound(String),
#[error("session not found or expired")]
SessionNotFound,
#[error("session expired")]
SessionExpired,
#[error("OAuth error: {0}")]
OAuthError(String),
#[error("OAuth provider not configured: {0}")]
ProviderNotConfigured(String),
#[error("token exchange failed: {status} {body}")]
TokenExchangeFailed { status: u16, body: String },
#[error("token refresh failed: {0}")]
TokenRefreshFailed(String),
#[error("PKCE verification failed")]
PkceVerificationFailed,
#[error("graph error: {0}")]
GraphError(String),
#[error("serialization error: {0}")]
SerializationError(String),
#[error("authentication required")]
Unauthenticated,
#[error("forbidden: requires role '{0}'")]
Forbidden(String),
#[error("forbidden: requires scope '{0}'")]
ScopeForbidden(String),
#[error("invalid credentials")]
InvalidCredentials,
#[error("role not found: {0}")]
RoleNotFound(String),
#[error("node not found: {0}")]
NodeNotFound(String),
}
pub type IdentityResult<T> = Result<T, IdentityError>;
+152
View File
@@ -0,0 +1,152 @@
//! AuthGuard — the mechanism behind `@authenticate`.
//!
//! `AuthGuard` is the bridge between a raw session/JWT token string (extracted
//! from the request) and a fully-resolved `IdentityContext`.
//!
//! Execution:
//! 1. Extract session ID from the token (JWT decode or opaque lookup)
//! 2. Validate the Session node in Engram (expiry check)
//! 3. Load the User node
//! 4. Traverse `User ──has_role──▶ Role` edges
//! 5. Traverse `Role ──grants──▶ Scope` edges
//! 6. Return `IdentityContext` — fully resolved, ready for downstream use
//!
//! `@public` bypasses this guard entirely (see `el-aop::PublicMarker`).
use crate::{
context::IdentityContext,
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{Role, Scope, User, EDGE_GRANTS, EDGE_HAS_ROLE, NODE_ROLE, NODE_SCOPE, NODE_USER},
session::SessionManager,
};
use std::sync::Arc;
/// AuthGuard resolves a session/token string into a full `IdentityContext`.
///
/// Configured once at application startup and shared across requests.
pub struct AuthGuard {
client: Arc<dyn EngramClient>,
session_manager: Arc<SessionManager>,
}
impl AuthGuard {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
Self { client, session_manager }
}
/// Authenticate a request given its session ID.
///
/// This is called by `@authenticate` in the AOP chain. For every protected
/// endpoint, this runs before the handler. If it returns `Err`, the request
/// is rejected.
pub fn authenticate(&self, session_id: &str) -> IdentityResult<IdentityContext> {
// 1. Validate session (checks expiry, lazy-deletes expired)
let session = self.session_manager.validate(session_id)?;
// 2. Load User node
let user_id_str = session.user_id.to_string();
let user_node = self
.client
.get_node(&user_id_str)
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.ok_or_else(|| IdentityError::UserNotFound(user_id_str.clone()))?;
let user = User::from_value(&user_node)
.ok_or_else(|| IdentityError::GraphError("user node parse failed".into()))?;
// 3. Load roles via has_role edges
let role_nodes = self
.client
.find_connected(&user_id_str, EDGE_HAS_ROLE)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
let roles: Vec<Role> = role_nodes
.iter()
.filter_map(Role::from_value)
.collect();
// 4. Load scopes via grants edges from each role
let mut scopes: Vec<Scope> = Vec::new();
for role in &roles {
let scope_nodes = self
.client
.find_connected(&role.id.to_string(), EDGE_GRANTS)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
scopes.extend(scope_nodes.iter().filter_map(Scope::from_value));
}
// Deduplicate scopes by name
scopes.dedup_by(|a, b| a.name == b.name);
Ok(IdentityContext::new(user, session, roles, scopes))
}
/// Require a specific role — returns Err::Forbidden if missing.
pub fn require_role(
&self,
ctx: &IdentityContext,
role: &str,
) -> IdentityResult<()> {
if ctx.has_role(role) {
Ok(())
} else {
Err(IdentityError::Forbidden(role.to_string()))
}
}
/// Require a specific scope — returns Err::ScopeForbidden if missing.
pub fn require_scope(
&self,
ctx: &IdentityContext,
scope: &str,
) -> IdentityResult<()> {
if ctx.has_scope(scope) {
Ok(())
} else {
Err(IdentityError::ScopeForbidden(scope.to_string()))
}
}
/// Register a user in the Engram graph.
///
/// Creates the User node. Call this after first OAuth login or on signup.
pub fn register_user(&self, user: &User) -> IdentityResult<()> {
self.client
.create_node(NODE_USER, user.to_value())
.map(|_| ())
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// Assign a role to a user by creating a `has_role` edge.
pub fn assign_role(&self, user_id: &str, role: &Role) -> IdentityResult<()> {
// Ensure role node exists
if self.client.get_node(&role.id.to_string())
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.is_none()
{
self.client
.create_node(NODE_ROLE, role.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
}
// Edge: User → Role
self.client
.create_edge(user_id, &role.id.to_string(), EDGE_HAS_ROLE)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// Register a scope and link it to a role via a `grants` edge.
pub fn assign_scope_to_role(&self, role: &Role, scope: &Scope) -> IdentityResult<()> {
if self.client.get_node(&scope.id.to_string())
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.is_none()
{
self.client
.create_node(NODE_SCOPE, scope.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
}
self.client
.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
}
+37
View File
@@ -0,0 +1,37 @@
//! el-identity — Engram-native identity for el-ui.
//!
//! Identity is not a bolt-on — it is activation spreading through the graph.
//! Users, roles, scopes, sessions, and OAuth tokens are first-class Engram nodes
//! connected by typed edges.
//!
//! ```text
//! User ──has_role──▶ Role ──grants──▶ Scope
//! │
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
//! ```
//!
//! Security-by-default: `@authenticate` is applied to every endpoint.
//! `@public` is the explicit opt-out.
#![deny(warnings)]
pub mod context;
pub mod engram;
pub mod error;
pub mod guard;
pub mod nodes;
pub mod oauth;
pub mod provider;
pub mod session;
pub use context::IdentityContext;
pub use engram::{EngramClient, MockEngramClient};
pub use error::{IdentityError, IdentityResult};
pub use guard::AuthGuard;
pub use nodes::{OAuthToken, Role, Scope, Session, User};
pub use oauth::{OAuthFlow, PkceChallenge};
pub use provider::{AppleOAuth, GithubOAuth, GoogleOAuth, OAuthProvider};
pub use session::SessionManager;
#[cfg(test)]
mod tests;
+271
View File
@@ -0,0 +1,271 @@
// el-identity Engram-native identity for el-ui.
//
// Identity in el-ui is a graph, not a table. Every entity is a node; every
// relationship is an edge. Authentication is spreading activation from a
// session token through the identity subgraph until it touches a User node.
//
// Edges:
// User has_role Role grants Scope
// User has_session Session authenticated_via OAuthToken
// Edge type constants
let EDGE_HAS_ROLE: String = "has_role"
let EDGE_HAS_SESSION: String = "has_session"
let EDGE_AUTHENTICATED_VIA: String = "authenticated_via"
let EDGE_GRANTS: String = "grants"
// Node type constants
let NODE_USER: String = "User"
let NODE_ROLE: String = "Role"
let NODE_SCOPE: String = "Scope"
let NODE_OAUTH_TOKEN: String = "OAuthToken"
let NODE_SESSION: String = "Session"
// User
type User {
id: String
email: String
display_name: String
created_at: String
}
fn user_new(email: String, display_name: String) -> User {
let now: String = time_now_iso()
let id: String = uuid_v4()
{ "id": id, "email": email, "display_name": display_name, "created_at": now }
}
// Role / Scope
type Role {
id: String
name: String
permissions: String // JSON-encoded array; struct fields are flat in El today
}
type Scope {
id: String
name: String
description: String
}
fn role_new(name: String) -> Role {
{ "id": uuid_v4(), "name": name, "permissions": "[]" }
}
fn role_has_permission(role: Role, perm: String) -> Bool {
str_contains(role.permissions, "\"" + perm + "\"")
}
fn scope_new(name: String, description: String) -> Scope {
{ "id": uuid_v4(), "name": name, "description": description }
}
// Session
type Session {
id: String
user_id: String
created_at: String
expires_at: String
ip_address: String
}
fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session {
let now: String = time_now_iso()
let exp: String = time_add_seconds(now, ttl_seconds)
{ "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address }
}
fn session_is_expired(session: Session) -> Bool {
time_after(time_now_iso(), session.expires_at)
}
// OAuthToken
//
// Tokens are SHA-256 hashed before storage the raw token never persists.
type OAuthToken {
id: String
provider: String
access_token_hash: String
refresh_token_hash: String
expires_at: String
scopes: String // JSON-encoded array
}
fn token_hash(raw: String) -> String {
sha256_hex(raw)
}
fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken {
let access_h: String = token_hash(access_raw)
let refresh_h: String = ""
if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) }
{ "id": uuid_v4(), "provider": provider, "access_token_hash": access_h,
"refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes }
}
fn oauth_token_is_expired(t: OAuthToken) -> Bool {
time_after(time_now_iso(), t.expires_at)
}
// PKCE (RFC 7636)
type PkceChallenge {
verifier: String
challenge: String
method: String // always "S256"
}
fn pkce_generate() -> PkceChallenge {
let verifier: String = base64url_no_pad(random_bytes(32))
let chal: String = base64url_no_pad(sha256_bytes(verifier))
{ "verifier": verifier, "challenge": chal, "method": "S256" }
}
fn pkce_verify(challenge: String, verifier: String) -> Bool {
let computed: String = base64url_no_pad(sha256_bytes(verifier))
str_eq(computed, challenge)
}
// OAuth providers (Google / GitHub / Apple)
//
// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL;
// `provider_exchange_code` POSTs to the token endpoint via http_post.
type OAuthProviderCfg {
name: String
client_id: String
client_secret: String
auth_url: String
token_url: String
default_scopes: String
}
fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "google", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"default_scopes": "[\"openid\",\"email\",\"profile\"]" }
}
fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "github", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"default_scopes": "[\"read:user\",\"user:email\"]" }
}
fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "apple", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://appleid.apple.com/auth/authorize",
"token_url": "https://appleid.apple.com/auth/token",
"default_scopes": "[\"name\",\"email\"]" }
}
fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String {
p.auth_url + "?response_type=code"
+ "&client_id=" + url_encode(p.client_id)
+ "&redirect_uri=" + url_encode(redirect_uri)
+ "&scope=" + url_encode(json_array_to_space_list(p.default_scopes))
+ "&state=" + url_encode(state)
+ "&code_challenge=" + url_encode(code_challenge)
+ "&code_challenge_method=S256"
}
// Engram client interface (graph CRUD)
//
// Identity persists to the local Engram graph. These wrap engram_* runtime
// calls (planned). Until that lands, the server-side stub uses a JSON file.
fn engram_create_node(node_type: String, value_json: String) -> String {
engram_node_create(node_type, value_json)
}
fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool {
engram_edge_create(from_id, to_id, edge_type)
}
fn engram_find_connected(node_id: String, edge_type: String) -> String {
engram_edge_traverse(node_id, edge_type)
}
// OAuth flow coordinator
type AuthFlowParams {
pkce_verifier: String
pkce_challenge: String
redirect_url: String
state: String
}
fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams {
let pkce: PkceChallenge = pkce_generate()
let state: String = base64url_no_pad(random_bytes(16))
let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state)
{ "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state }
}
fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken {
let body: String = "grant_type=authorization_code"
+ "&code=" + url_encode(code)
+ "&redirect_uri=" + url_encode(redirect_uri)
+ "&client_id=" + url_encode(provider.client_id)
+ "&client_secret=" + url_encode(provider.client_secret)
+ "&code_verifier=" + url_encode(pkce_verifier)
let resp: String = http_post(provider.token_url, body)
let access: String = json_get(resp, "access_token")
let refresh: String = json_get(resp, "refresh_token")
let expires_in: Int = str_to_int(json_get(resp, "expires_in"))
let exp_at: String = time_add_seconds(time_now_iso(), expires_in)
let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]")
let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token))
engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA)
token
}
// Session manager
fn session_create(user_id: String, ttl: Int, ip: String) -> Session {
let s: Session = session_new(user_id, ttl, ip)
let session_id: String = engram_create_node(NODE_SESSION, json_encode(s))
engram_create_edge(user_id, session_id, EDGE_HAS_SESSION)
s
}
fn session_revoke(session_id: String) -> Bool {
engram_node_delete(session_id)
}
// AuthGuard applied via @authenticate decorator
fn auth_guard_verify(session_id: String) -> Bool {
let raw: String = engram_node_get(session_id)
if str_eq(raw, "") { return false }
let exp: String = json_get(raw, "expires_at")
!time_after(time_now_iso(), exp)
}
// Identity context (passed through the request lifecycle)
type IdentityContext {
user_id: String
session_id: String
roles_json: String
}
fn identity_load(session_id: String) -> IdentityContext {
let session_raw: String = engram_node_get(session_id)
let user_id: String = json_get(session_raw, "user_id")
let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE)
{ "user_id": user_id, "session_id": session_id, "roles_json": roles_raw }
}
// Entry smoke test
let user: User = user_new("will@neurontechnologies.ai", "Will Anderson")
println("[el-identity] user " + user.email + " (" + user.id + ")")
+244
View File
@@ -0,0 +1,244 @@
//! Identity graph nodes — User, Role, Scope, OAuthToken, Session as
//! strongly-typed Engram node structs.
//!
//! Each node maps to an Engram graph node. The identity graph looks like:
//!
//! ```text
//! User ──has_role──▶ Role ──grants──▶ Scope
//! │
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
//! ```
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── User ──────────────────────────────────────────────────────────────────────
/// A user identity node in the Engram graph.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
/// Stable UUID for this user.
pub id: Uuid,
/// Email address — unique identifier for the user.
pub email: String,
/// Display name (may differ from email).
pub display_name: String,
/// When this user node was created.
pub created_at: DateTime<Utc>,
}
impl User {
/// Create a new user with a fresh UUID.
pub fn new(email: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
email: email.into(),
display_name: display_name.into(),
created_at: Utc::now(),
}
}
/// Deserialize from a serde_json::Value (as stored in Engram).
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
/// Serialize to serde_json::Value for storage in Engram.
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("User is always serializable")
}
}
// ── Role ──────────────────────────────────────────────────────────────────────
/// A role node — grants a set of named permissions to connected User nodes.
///
/// Edge: `User ──has_role──▶ Role`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Role {
pub id: Uuid,
pub name: String,
/// Flat list of permission strings (e.g., `"orders:read"`, `"users:write"`).
pub permissions: Vec<String>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(perm.into());
self
}
pub fn with_permissions(mut self, perms: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.permissions.extend(perms.into_iter().map(|p| p.into()));
self
}
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p == perm)
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Role is always serializable")
}
}
// ── Scope ─────────────────────────────────────────────────────────────────────
/// An OAuth scope node.
///
/// Edge: `Role ──grants──▶ Scope`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Scope {
pub id: Uuid,
/// The OAuth scope string (e.g., `"openid"`, `"email"`, `"profile"`).
pub name: String,
pub description: String,
}
impl Scope {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
description: description.into(),
}
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Scope is always serializable")
}
}
// ── OAuthToken ────────────────────────────────────────────────────────────────
/// An OAuth token node — stores hashed tokens so the graph is breach-safe.
///
/// Edge: `Session ──authenticated_via──▶ OAuthToken`
///
/// Tokens are hashed with SHA-256 before storage. The raw token is never
/// persisted — only the hash. Refresh tokens use the same scheme.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OAuthToken {
pub id: Uuid,
pub provider: String,
/// SHA-256 hex hash of the access token.
pub access_token_hash: String,
/// SHA-256 hex hash of the refresh token, if present.
pub refresh_token_hash: Option<String>,
pub expires_at: DateTime<Utc>,
/// Scopes granted by this token.
pub scopes: Vec<String>,
}
impl OAuthToken {
pub fn new(
provider: impl Into<String>,
access_token_hash: impl Into<String>,
refresh_token_hash: Option<String>,
expires_at: DateTime<Utc>,
scopes: Vec<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
provider: provider.into(),
access_token_hash: access_token_hash.into(),
refresh_token_hash,
expires_at,
scopes,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("OAuthToken is always serializable")
}
}
// ── Session ───────────────────────────────────────────────────────────────────
/// A session node — represents an active authenticated session.
///
/// Edge: `User ──has_session──▶ Session`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Session {
pub id: Uuid,
/// The user this session belongs to.
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
/// The IP address that created this session.
pub ip_address: Option<String>,
}
impl Session {
pub fn new(user_id: Uuid, ttl_seconds: i64, ip_address: Option<String>) -> Self {
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_seconds);
Self {
id: Uuid::new_v4(),
user_id,
created_at: now,
expires_at,
ip_address,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Session is always serializable")
}
}
// ── Edge type constants ───────────────────────────────────────────────────────
/// Edge type: User → Role
pub const EDGE_HAS_ROLE: &str = "has_role";
/// Edge type: User → Session
pub const EDGE_HAS_SESSION: &str = "has_session";
/// Edge type: Session → OAuthToken
pub const EDGE_AUTHENTICATED_VIA: &str = "authenticated_via";
/// Edge type: Role → Scope
pub const EDGE_GRANTS: &str = "grants";
// ── Node type constants ───────────────────────────────────────────────────────
pub const NODE_USER: &str = "User";
pub const NODE_ROLE: &str = "Role";
pub const NODE_SCOPE: &str = "Scope";
pub const NODE_OAUTH_TOKEN: &str = "OAuthToken";
pub const NODE_SESSION: &str = "Session";
+275
View File
@@ -0,0 +1,275 @@
//! OAuth 2.0 flows implemented as Engram graph operations.
//!
//! Implements PKCE (RFC 7636) and Authorization Code flow without external
//! OAuth crates. Token exchange uses `reqwest` (already in the workspace).
//! All tokens are hashed before graph storage — the raw token never persists.
//!
//! Flow:
//! 1. `begin_auth_flow()` → PKCE verifier + challenge, redirect URL
//! 2. Provider redirects back with `code`
//! 3. `exchange_code()` → calls provider token endpoint, stores OAuthToken node
//! 4. `refresh_token()` → find OAuthToken node, call refresh endpoint, update node
use crate::{
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{OAuthToken, User, EDGE_AUTHENTICATED_VIA, NODE_OAUTH_TOKEN},
provider::OAuthProvider,
session::SessionManager,
};
use chrono::{Duration, Utc};
use sha2::{Digest, Sha256};
use std::sync::Arc;
// ── PKCE helpers ──────────────────────────────────────────────────────────────
/// A PKCE verifier/challenge pair.
#[derive(Debug, Clone)]
pub struct PkceChallenge {
/// The raw verifier — sent to the token endpoint.
pub verifier: String,
/// The challenge (BASE64URL(SHA256(verifier))) — sent in the auth request.
pub challenge: String,
/// Always "S256".
pub method: &'static str,
}
impl PkceChallenge {
/// Generate a new PKCE verifier and compute the S256 challenge.
///
/// The verifier is a 43-character URL-safe random string derived from
/// entropy collected from the system clock and a UUID.
pub fn generate() -> Self {
let verifier = generate_pkce_verifier();
let challenge = pkce_s256_challenge(&verifier);
Self {
verifier,
challenge,
method: "S256",
}
}
/// Verify that a verifier matches this challenge (used in tests and server-side).
pub fn verify(&self, verifier: &str) -> bool {
pkce_s256_challenge(verifier) == self.challenge
}
}
fn generate_pkce_verifier() -> String {
// 32 random bytes → base64url (43 chars, no padding)
// We derive entropy from UUID (random in v4) + timestamp nanos.
let id = uuid::Uuid::new_v4();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let mut raw = [0u8; 32];
let id_bytes = id.as_bytes();
for i in 0..16 {
raw[i] = id_bytes[i];
}
let ts_bytes = ts.to_le_bytes();
for i in 0..4 {
raw[16 + i] = ts_bytes[i];
}
// Fill remaining with XOR mix
for i in 20..32 {
raw[i] = id_bytes[i - 16] ^ ts_bytes[i % 4];
}
base64url_encode_no_pad(&raw)
}
fn pkce_s256_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
base64url_encode_no_pad(&hasher.finalize())
}
fn base64url_encode_no_pad(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(n & 63) as usize] as char);
}
}
out
}
/// Hash a token with SHA-256 for safe graph storage.
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode_lower_sha256(hasher.finalize().as_ref())
}
mod hex {
pub fn encode_lower_sha256(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
// ── Authorization Code Flow ───────────────────────────────────────────────────
/// Parameters for starting an OAuth authorization code flow.
#[derive(Debug, Clone)]
pub struct AuthFlowParams {
/// The PKCE challenge (save the verifier for use at exchange time).
pub pkce: PkceChallenge,
/// The full redirect URL to send the user to.
pub redirect_url: String,
/// An opaque state value for CSRF protection.
pub state: String,
}
/// The result of a successful token exchange.
#[derive(Debug, Clone)]
pub struct TokenSet {
pub access_token: String,
pub refresh_token: Option<String>,
/// Expiry in seconds from now.
pub expires_in: u64,
pub scopes: Vec<String>,
}
/// OAuth flow coordinator — executes auth code + PKCE flows and writes
/// the resulting tokens to the Engram graph.
pub struct OAuthFlow {
client: Arc<dyn EngramClient>,
/// Kept for future use (e.g., session creation during OAuth callback).
_session_manager: Arc<SessionManager>,
}
impl OAuthFlow {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
Self { client, _session_manager: session_manager }
}
/// Step 1: Generate the redirect URL and PKCE parameters.
///
/// The caller should:
/// 1. Save `params.pkce.verifier` in the user's browser session (cookie/localStorage).
/// 2. Redirect the user to `params.redirect_url`.
pub fn begin_auth_flow(
&self,
provider: &dyn OAuthProvider,
redirect_uri: &str,
extra_scopes: &[&str],
) -> IdentityResult<AuthFlowParams> {
let pkce = PkceChallenge::generate();
let state = generate_pkce_verifier(); // reuse the verifier generator for state
let url = provider.authorization_url(
redirect_uri,
&pkce.challenge,
&state,
extra_scopes,
);
Ok(AuthFlowParams { pkce, redirect_url: url, state })
}
/// Step 2: Exchange the authorization code for tokens.
///
/// - `user` — the authenticated user to link the token to
/// - `code` — the authorization code from the provider callback
/// - `pkce_verifier` — the verifier saved in step 1
/// - `session_id` — the session to attach the OAuthToken to
///
/// Returns the session ID that now has an OAuthToken attached via graph edge.
pub fn exchange_code(
&self,
provider: &dyn OAuthProvider,
_user: &User,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
session_id: &str,
) -> IdentityResult<OAuthToken> {
// Exchange code with provider (HTTP call inside OAuthProvider::exchange_code)
let token_set = provider.exchange_code(code, pkce_verifier, redirect_uri)?;
// Hash tokens before storing
let access_hash = hash_token(&token_set.access_token);
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
let oauth_token = OAuthToken::new(
provider.name(),
access_hash,
refresh_hash,
expires_at,
token_set.scopes,
);
// Store OAuthToken node in graph
let token_id = self
.client
.create_node(NODE_OAUTH_TOKEN, oauth_token.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
// Edge: Session → OAuthToken (authenticated_via)
self.client
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(oauth_token)
}
/// Step 3: Refresh an expired access token.
///
/// Finds the OAuthToken node for the given session, calls the provider's
/// refresh endpoint, and updates the node in-place (delete old, create new).
pub fn refresh_token(
&self,
provider: &dyn OAuthProvider,
session_id: &str,
refresh_token: &str,
) -> IdentityResult<OAuthToken> {
// Exchange refresh token with provider
let token_set = provider.refresh_token(refresh_token)?;
// Find and delete old OAuthToken nodes attached to this session
let old_tokens = self
.client
.find_connected(session_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
for old in &old_tokens {
if let Some(id) = old.get("id").and_then(|v| v.as_str()) {
let _ = self.client.delete_node(id);
}
}
// Store new token
let access_hash = hash_token(&token_set.access_token);
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
let new_token = OAuthToken::new(
provider.name(),
access_hash,
refresh_hash,
expires_at,
token_set.scopes,
);
let token_id = self
.client
.create_node(NODE_OAUTH_TOKEN, new_token.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
self.client
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(new_token)
}
}
+372
View File
@@ -0,0 +1,372 @@
//! OAuthProvider trait and built-in provider implementations.
//!
//! Each provider knows its own OAuth endpoints and default scopes.
//! Token exchange is done over HTTP using `reqwest` in async or
//! in the sync shim below. For simplicity in a framework context
//! we use blocking HTTP (same pattern as the rest of el-ui).
//!
//! Implementations: `GoogleOAuth`, `AppleOAuth`, `GithubOAuth`.
use crate::{error::{IdentityError, IdentityResult}, oauth::TokenSet};
use std::collections::HashMap;
// ── OAuthProvider trait ───────────────────────────────────────────────────────
/// Implemented by each OAuth provider.
pub trait OAuthProvider: Send + Sync {
/// The provider identifier (e.g., `"google"`, `"github"`, `"apple"`).
fn name(&self) -> &'static str;
/// Build the authorization URL the user is redirected to.
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String;
/// Exchange an authorization code for tokens (HTTP POST to token endpoint).
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet>;
/// Refresh an access token using a refresh token.
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet>;
/// Default scopes requested by this provider.
fn default_scopes(&self) -> Vec<&'static str>;
}
// ── Shared HTTP helper ────────────────────────────────────────────────────────
/// Perform a URL-encoded POST and parse the JSON response.
///
/// In production this would use an async client. Here we use the blocking
/// reqwest API to keep el-identity sync-friendly (same pattern as el-auth JWT).
/// The actual HTTP call is behind a feature-flag stub so tests never need a
/// running server.
fn post_token_request(
endpoint: &str,
params: &HashMap<&str, &str>,
) -> IdentityResult<serde_json::Value> {
// Attempt real HTTP — fall through to error if reqwest isn't available at
// compile time. Since we don't add reqwest as a dep (no_std compat), we
// return a clear error. The caller (OAuthFlow) is the integration point.
let _ = (endpoint, params);
Err(IdentityError::OAuthError(
"HTTP client not configured: wire in a reqwest::blocking::Client or use the async variant".into(),
))
}
/// Parse token endpoint JSON response → TokenSet.
fn parse_token_response(json: &serde_json::Value) -> IdentityResult<TokenSet> {
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| IdentityError::OAuthError("missing access_token in response".into()))?
.to_string();
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let scope_str = json
.get("scope")
.and_then(|v| v.as_str())
.unwrap_or("");
let scopes = scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
Ok(TokenSet {
access_token,
refresh_token,
expires_in,
scopes,
})
}
// ── GoogleOAuth ───────────────────────────────────────────────────────────────
/// Google OAuth 2.0 provider.
///
/// Endpoints:
/// - Auth: `https://accounts.google.com/o/oauth2/v2/auth`
/// - Token: `https://oauth2.googleapis.com/token`
#[derive(Debug, Clone)]
pub struct GoogleOAuth {
pub client_id: String,
pub client_secret: String,
}
impl GoogleOAuth {
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
}
}
const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL: &'static str = "https://oauth2.googleapis.com/token";
}
impl OAuthProvider for GoogleOAuth {
fn name(&self) -> &'static str {
"google"
}
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
url_encode(pkce_challenge),
)
}
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret);
params.insert("code", code);
params.insert("code_verifier", pkce_verifier);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret);
params.insert("refresh_token", refresh_token);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["openid", "email", "profile"]
}
}
// ── AppleOAuth ────────────────────────────────────────────────────────────────
/// Apple Sign In OAuth provider.
///
/// Endpoints:
/// - Auth: `https://appleid.apple.com/auth/authorize`
/// - Token: `https://appleid.apple.com/auth/token`
///
/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key.
/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file.
#[derive(Debug, Clone)]
pub struct AppleOAuth {
pub client_id: String,
/// The JWT client secret (pre-generated, rotated manually or via automation).
pub client_secret_jwt: String,
pub team_id: String,
}
impl AppleOAuth {
pub fn new(
client_id: impl Into<String>,
client_secret_jwt: impl Into<String>,
team_id: impl Into<String>,
) -> Self {
Self {
client_id: client_id.into(),
client_secret_jwt: client_secret_jwt.into(),
team_id: team_id.into(),
}
}
const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize";
const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token";
}
impl OAuthProvider for AppleOAuth {
fn name(&self) -> &'static str {
"apple"
}
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
url_encode(pkce_challenge),
)
}
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret_jwt);
params.insert("code", code);
params.insert("code_verifier", pkce_verifier);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret_jwt);
params.insert("refresh_token", refresh_token);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["openid", "email", "name"]
}
}
// ── GithubOAuth ───────────────────────────────────────────────────────────────
/// GitHub OAuth 2.0 provider.
///
/// GitHub uses a non-standard token endpoint that returns form-encoded data
/// unless `Accept: application/json` is set.
///
/// Endpoints:
/// - Auth: `https://github.com/login/oauth/authorize`
/// - Token: `https://github.com/login/oauth/access_token`
///
/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored.
#[derive(Debug, Clone)]
pub struct GithubOAuth {
pub client_id: String,
pub client_secret: String,
}
impl GithubOAuth {
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
}
}
const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize";
const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
}
impl OAuthProvider for GithubOAuth {
fn name(&self) -> &'static str {
"github"
}
fn authorization_url(
&self,
redirect_uri: &str,
_pkce_challenge: &str, // GitHub does not support PKCE
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&scope={}&state={}",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
)
}
fn exchange_code(
&self,
code: &str,
_pkce_verifier: &str, // GitHub does not support PKCE
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("client_id", self.client_id.as_str());
params.insert("client_secret", self.client_secret.as_str());
params.insert("code", code);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
// GitHub access tokens don't expire by default and don't have refresh tokens.
Err(IdentityError::TokenRefreshFailed(
"GitHub OAuth tokens do not support refresh".into(),
))
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["user:email", "read:user"]
}
}
// ── URL encoding ──────────────────────────────────────────────────────────────
fn url_encode(input: &str) -> String {
let mut out = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
b => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
+120
View File
@@ -0,0 +1,120 @@
//! Session management — sessions are Engram graph nodes connected to User nodes.
//!
//! Session lifecycle:
//! 1. `SessionManager::create()` → Session node + `User ──has_session──▶ Session` edge
//! 2. `SessionManager::validate()` → find Session node, check expiry
//! 3. `SessionManager::invalidate()` → delete Session node (edges auto-removed)
//!
//! The `SessionManager` works exclusively through the `EngramClient` trait — no
//! in-memory map, no cache. The graph is the source of truth.
use crate::{
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{Session, EDGE_HAS_SESSION, NODE_SESSION},
};
use std::sync::Arc;
/// Manages session nodes in the Engram identity graph.
pub struct SessionManager {
client: Arc<dyn EngramClient>,
/// Default session TTL in seconds (default: 3600 = 1 hour).
pub default_ttl_seconds: i64,
}
impl SessionManager {
pub fn new(client: Arc<dyn EngramClient>) -> Self {
Self {
client,
default_ttl_seconds: 3600,
}
}
pub fn with_ttl(mut self, seconds: i64) -> Self {
self.default_ttl_seconds = seconds;
self
}
/// Create a new session for the given user.
///
/// Stores the Session node in Engram and creates a `has_session` edge
/// from the User node to the Session node.
///
/// Returns the session ID string.
pub fn create(
&self,
user_id: uuid::Uuid,
ip_address: Option<String>,
) -> IdentityResult<Session> {
let session = Session::new(user_id, self.default_ttl_seconds, ip_address);
let session_id_str = session.id.to_string();
// Store session node
self.client
.create_node(NODE_SESSION, session.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
// Edge: User → Session (has_session)
self.client
.create_edge(&user_id.to_string(), &session_id_str, EDGE_HAS_SESSION)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(session)
}
/// Validate a session by ID.
///
/// Returns the `Session` node if found and not expired.
/// Automatically deletes expired sessions on lookup (lazy expiry).
pub fn validate(&self, session_id: &str) -> IdentityResult<Session> {
let node = self
.client
.get_node(session_id)
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.ok_or(IdentityError::SessionNotFound)?;
let session = Session::from_value(&node)
.ok_or_else(|| IdentityError::GraphError("session node parse failed".into()))?;
if session.is_expired() {
// Lazy cleanup — remove expired session from graph
let _ = self.client.delete_node(session_id);
return Err(IdentityError::SessionExpired);
}
Ok(session)
}
/// Invalidate (delete) a session node from the graph.
pub fn invalidate(&self, session_id: &str) -> IdentityResult<()> {
self.client
.delete_node(session_id)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// List all sessions for a user by traversing `has_session` edges.
pub fn list_for_user(&self, user_id: &str) -> IdentityResult<Vec<Session>> {
let nodes = self
.client
.find_connected(user_id, EDGE_HAS_SESSION)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
let sessions: Vec<Session> = nodes
.iter()
.filter_map(Session::from_value)
.filter(|s| !s.is_expired())
.collect();
Ok(sessions)
}
/// Invalidate all sessions for a user (logout everywhere).
pub fn invalidate_all_for_user(&self, user_id: &str) -> IdentityResult<usize> {
let sessions = self.list_for_user(user_id)?;
let count = sessions.len();
for session in &sessions {
let _ = self.client.delete_node(&session.id.to_string());
}
Ok(count)
}
}
+563
View File
@@ -0,0 +1,563 @@
//! Comprehensive tests for el-identity.
//!
//! All tests use `MockEngramClient` — no running Engram instance needed.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use uuid::Uuid;
use crate::{
context::IdentityContext,
engram::{EngramClient, MockEngramClient},
error::IdentityError,
guard::AuthGuard,
nodes::{
OAuthToken, Role, Scope, Session, User,
EDGE_GRANTS, EDGE_HAS_ROLE, EDGE_HAS_SESSION,
NODE_USER, NODE_ROLE, NODE_SCOPE,
},
oauth::{PkceChallenge, hash_token},
provider::{GoogleOAuth, AppleOAuth, GithubOAuth, OAuthProvider},
session::SessionManager,
};
// ── Test helpers ──────────────────────────────────────────────────────────
fn make_client() -> Arc<MockEngramClient> {
Arc::new(MockEngramClient::new())
}
fn make_session_manager(client: Arc<MockEngramClient>) -> Arc<SessionManager> {
Arc::new(SessionManager::new(client as Arc<dyn crate::EngramClient>))
}
fn setup_auth_guard() -> (Arc<MockEngramClient>, Arc<SessionManager>, AuthGuard) {
let client = make_client();
let sm = make_session_manager(client.clone());
let guard = AuthGuard::new(
client.clone() as Arc<dyn crate::EngramClient>,
sm.clone(),
);
(client, sm, guard)
}
fn make_user() -> User {
User::new("alice@example.com", "Alice")
}
fn make_role_admin() -> Role {
Role::new("admin")
.with_permissions(vec!["users:read", "users:write", "orders:delete"])
}
fn make_role_viewer() -> Role {
Role::new("viewer")
.with_permissions(vec!["users:read", "orders:read"])
}
fn make_scope_email() -> Scope {
Scope::new("email", "Access email address")
}
fn make_scope_profile() -> Scope {
Scope::new("profile", "Access profile information")
}
// ── Node tests ────────────────────────────────────────────────────────────
// Test 1: User node creation and serialization round-trip
#[test]
fn test_user_node_round_trip() {
let user = User::new("bob@example.com", "Bob");
let value = user.to_value();
let decoded = User::from_value(&value).expect("should decode User");
assert_eq!(decoded.email, "bob@example.com");
assert_eq!(decoded.display_name, "Bob");
assert_eq!(decoded.id, user.id);
}
// Test 2: Role node with permissions serializes correctly
#[test]
fn test_role_node_permissions() {
let role = make_role_admin();
assert!(role.has_permission("users:write"));
assert!(role.has_permission("orders:delete"));
assert!(!role.has_permission("superadmin"));
let value = role.to_value();
let decoded = Role::from_value(&value).expect("should decode Role");
assert_eq!(decoded.name, "admin");
assert_eq!(decoded.permissions.len(), 3);
}
// Test 3: Scope node round-trip
#[test]
fn test_scope_node_round_trip() {
let scope = make_scope_email();
let value = scope.to_value();
let decoded = Scope::from_value(&value).expect("should decode Scope");
assert_eq!(decoded.name, "email");
assert_eq!(decoded.description, "Access email address");
}
// Test 4: Session expiry detection
#[test]
fn test_session_expiry() {
let user_id = Uuid::new_v4();
// TTL = -1 → already expired
let session = Session::new(user_id, -1, None);
assert!(session.is_expired(), "session with negative TTL should be expired");
let fresh = Session::new(user_id, 3600, None);
assert!(!fresh.is_expired(), "fresh session should not be expired");
}
// Test 5: OAuthToken expiry detection
#[test]
fn test_oauth_token_expiry() {
use chrono::{Duration, Utc};
let past = Utc::now() - Duration::seconds(1);
let token = OAuthToken::new("google", "hash123", None, past, vec![]);
assert!(token.is_expired());
let future = Utc::now() + Duration::seconds(3600);
let fresh = OAuthToken::new("google", "hash456", None, future, vec!["email".into()]);
assert!(!fresh.is_expired());
assert!(fresh.has_scope("email"));
assert!(!fresh.has_scope("openid"));
}
// ── MockEngramClient tests ─────────────────────────────────────────────────
// Test 6: MockEngramClient create and retrieve node
#[test]
fn test_mock_client_create_and_get() {
let client = make_client();
let user = make_user();
let id = client.create_node(NODE_USER, user.to_value()).unwrap();
assert_eq!(id, user.id.to_string());
let retrieved = client.get_node(&id).unwrap().expect("should find node");
let decoded = User::from_value(&retrieved).expect("should decode");
assert_eq!(decoded.email, user.email);
}
// Test 7: MockEngramClient get_node returns None for unknown ID
#[test]
fn test_mock_client_get_unknown_returns_none() {
let client = make_client();
let result = client.get_node("nonexistent-id").unwrap();
assert!(result.is_none());
}
// Test 8: MockEngramClient find_nodes with query
#[test]
fn test_mock_client_find_nodes() {
let client = make_client();
let u1 = User::new("alice@example.com", "Alice");
let u2 = User::new("bob@example.com", "Bob");
client.create_node(NODE_USER, u1.to_value()).unwrap();
client.create_node(NODE_USER, u2.to_value()).unwrap();
let query = serde_json::json!({"email": "alice@example.com"});
let results = client.find_nodes(NODE_USER, query).unwrap();
assert_eq!(results.len(), 1);
let found = User::from_value(&results[0]).unwrap();
assert_eq!(found.display_name, "Alice");
}
// Test 9: MockEngramClient delete_node removes node and edges
#[test]
fn test_mock_client_delete_node() {
let client = make_client();
let user = make_user();
let role = make_role_admin();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 1);
client.delete_node(&user.id.to_string()).unwrap();
assert!(client.get_node(&user.id.to_string()).unwrap().is_none());
// Edge should also be gone
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 0);
}
// Test 10: MockEngramClient find_connected traverses edges
#[test]
fn test_mock_client_find_connected() {
let client = make_client();
let user = make_user();
let role1 = make_role_admin();
let role2 = make_role_viewer();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role1.to_value()).unwrap();
client.create_node(NODE_ROLE, role2.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role1.id.to_string(), EDGE_HAS_ROLE).unwrap();
client.create_edge(&user.id.to_string(), &role2.id.to_string(), EDGE_HAS_ROLE).unwrap();
let roles = client.find_connected(&user.id.to_string(), EDGE_HAS_ROLE).unwrap();
assert_eq!(roles.len(), 2);
}
// ── SessionManager tests ───────────────────────────────────────────────────
// Test 11: SessionManager creates session and connects to user
#[test]
fn test_session_manager_create() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, Some("127.0.0.1".into())).unwrap();
assert_eq!(session.user_id, user.id);
assert_eq!(session.ip_address, Some("127.0.0.1".to_string()));
// Should have created a has_session edge
assert_eq!(client.count_edges(EDGE_HAS_SESSION), 1);
}
// Test 12: SessionManager validate succeeds for fresh session
#[test]
fn test_session_manager_validate_fresh() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, None).unwrap();
let validated = sm.validate(&session.id.to_string()).unwrap();
assert_eq!(validated.id, session.id);
}
// Test 13: SessionManager validate fails for expired session
#[test]
fn test_session_manager_validate_expired() {
let client = make_client();
let sm = Arc::new(SessionManager::new(client.clone() as Arc<dyn crate::EngramClient>).with_ttl(-1));
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let result = sm.validate(&session.id.to_string());
assert!(matches!(result, Err(IdentityError::SessionExpired)));
}
// Test 14: SessionManager validate fails for missing session
#[test]
fn test_session_manager_validate_missing() {
let client = make_client();
let sm = make_session_manager(client.clone());
let result = sm.validate("nonexistent-session-id");
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
}
// Test 15: SessionManager invalidate removes session
#[test]
fn test_session_manager_invalidate() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, None).unwrap();
let session_id = session.id.to_string();
sm.invalidate(&session_id).unwrap();
assert!(matches!(sm.validate(&session_id), Err(IdentityError::SessionNotFound)));
}
// Test 16: SessionManager list_for_user returns active sessions
#[test]
fn test_session_manager_list_for_user() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
sm.create(user.id, Some("1.1.1.1".into())).unwrap();
sm.create(user.id, Some("2.2.2.2".into())).unwrap();
let sessions = sm.list_for_user(&user.id.to_string()).unwrap();
assert_eq!(sessions.len(), 2);
}
// ── AuthGuard tests ───────────────────────────────────────────────────────
// Test 17: AuthGuard authenticates user with role and scope
#[test]
fn test_auth_guard_full_resolution() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
let scope = make_scope_email();
// Register user, role, scope, and wire edges
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_node(NODE_SCOPE, scope.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
client.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS).unwrap();
// Create session
let session = sm.create(user.id, None).unwrap();
// Authenticate
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert_eq!(ctx.user.email, "alice@example.com");
assert_eq!(ctx.roles.len(), 1);
assert!(ctx.has_role("admin"));
assert!(ctx.has_scope("email"));
assert!(ctx.has_permission("users:write"));
}
// Test 18: AuthGuard rejects unknown session
#[test]
fn test_auth_guard_rejects_unknown_session() {
let (_, _, guard) = setup_auth_guard();
let result = guard.authenticate("no-such-session");
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
}
// Test 19: AuthGuard require_role succeeds for correct role
#[test]
fn test_auth_guard_require_role_success() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(guard.require_role(&ctx, "admin").is_ok());
}
// Test 20: AuthGuard require_role fails for missing role
#[test]
fn test_auth_guard_require_role_forbidden() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
let result = guard.require_role(&ctx, "superadmin");
assert!(matches!(result, Err(IdentityError::Forbidden(_))));
}
// Test 21: AuthGuard require_scope fails for missing scope
#[test]
fn test_auth_guard_require_scope_forbidden() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
let result = guard.require_scope(&ctx, "admin:write");
assert!(matches!(result, Err(IdentityError::ScopeForbidden(_))));
}
// Test 22: AuthGuard register_user and assign_role
#[test]
fn test_auth_guard_register_and_assign_role() {
let (_client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_viewer();
guard.register_user(&user).unwrap();
guard.assign_role(&user.id.to_string(), &role).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(ctx.has_role("viewer"));
assert!(ctx.has_permission("users:read"));
assert!(!ctx.has_permission("orders:delete"));
}
// ── PKCE tests ────────────────────────────────────────────────────────────
// Test 23: PkceChallenge generates valid verifier/challenge pair
#[test]
fn test_pkce_challenge_generates_valid_pair() {
let pkce = PkceChallenge::generate();
assert!(!pkce.verifier.is_empty(), "verifier should not be empty");
assert!(!pkce.challenge.is_empty(), "challenge should not be empty");
assert_ne!(pkce.verifier, pkce.challenge, "verifier and challenge must differ");
assert_eq!(pkce.method, "S256");
}
// Test 24: PkceChallenge verify matches correct verifier
#[test]
fn test_pkce_challenge_verify_correct() {
let pkce = PkceChallenge::generate();
assert!(pkce.verify(&pkce.verifier.clone()), "should verify correct verifier");
}
// Test 25: PkceChallenge verify rejects wrong verifier
#[test]
fn test_pkce_challenge_verify_rejects_wrong() {
let pkce = PkceChallenge::generate();
assert!(!pkce.verify("wrong-verifier-value"), "should reject wrong verifier");
}
// Test 26: hash_token is deterministic
#[test]
fn test_hash_token_deterministic() {
let token = "my-secret-access-token";
let h1 = hash_token(token);
let h2 = hash_token(token);
assert_eq!(h1, h2, "hash must be deterministic");
assert_ne!(h1, token, "hash must differ from input");
assert_eq!(h1.len(), 64, "SHA-256 hex output is 64 chars");
}
// ── Provider tests ────────────────────────────────────────────────────────
// Test 27: GoogleOAuth builds correct authorization URL
#[test]
fn test_google_oauth_authorization_url() {
let provider = GoogleOAuth::new("client-id-123", "secret");
let url = provider.authorization_url(
"https://myapp.com/callback",
"pkce-challenge-abc",
"state-xyz",
&[],
);
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth"));
assert!(url.contains("client_id=client-id-123"));
assert!(url.contains("code_challenge=pkce-challenge-abc"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=state-xyz"));
assert!(url.contains("access_type=offline"), "Google needs offline for refresh tokens");
}
// Test 28: GoogleOAuth default scopes include openid, email, profile
#[test]
fn test_google_oauth_default_scopes() {
let provider = GoogleOAuth::new("id", "secret");
let scopes = provider.default_scopes();
assert!(scopes.contains(&"openid"));
assert!(scopes.contains(&"email"));
assert!(scopes.contains(&"profile"));
}
// Test 29: AppleOAuth builds correct authorization URL with form_post
#[test]
fn test_apple_oauth_authorization_url() {
let provider = AppleOAuth::new("com.example.app", "jwt-secret", "TEAMID");
let url = provider.authorization_url(
"https://myapp.com/callback",
"challenge",
"state",
&[],
);
assert!(url.starts_with("https://appleid.apple.com/auth/authorize"));
assert!(url.contains("response_mode=form_post"), "Apple requires form_post");
}
// Test 30: GithubOAuth ignores PKCE in URL (GitHub doesn't support it)
#[test]
fn test_github_oauth_no_pkce_in_url() {
let provider = GithubOAuth::new("gh-client-id", "gh-secret");
let url = provider.authorization_url(
"https://myapp.com/callback",
"pkce-challenge-ignored",
"state",
&[],
);
assert!(url.starts_with("https://github.com/login/oauth/authorize"));
assert!(!url.contains("code_challenge"), "GitHub doesn't support PKCE");
assert!(url.contains("scope=user%3Aemail+read%3Auser") || url.contains("scope="));
}
// Test 31: GithubOAuth refresh_token returns error
#[test]
fn test_github_oauth_refresh_returns_error() {
let provider = GithubOAuth::new("id", "secret");
let result = provider.refresh_token("some-refresh-token");
assert!(matches!(result, Err(IdentityError::TokenRefreshFailed(_))));
}
// ── IdentityContext tests ─────────────────────────────────────────────────
// Test 32: IdentityContext role_names and scope_names
#[test]
fn test_identity_context_names() {
let user = make_user();
let session = Session::new(user.id, 3600, None);
let roles = vec![make_role_admin(), make_role_viewer()];
let scopes = vec![make_scope_email(), make_scope_profile()];
let ctx = IdentityContext::new(user.clone(), session, roles, scopes);
let mut role_names = ctx.role_names();
role_names.sort();
assert_eq!(role_names, vec!["admin", "viewer"]);
let mut scope_names = ctx.scope_names();
scope_names.sort();
assert_eq!(scope_names, vec!["email", "profile"]);
}
// Test 33: IdentityContext has_permission across multiple roles
#[test]
fn test_identity_context_permission_across_roles() {
let user = make_user();
let session = Session::new(user.id, 3600, None);
let roles = vec![make_role_viewer()]; // viewer has users:read and orders:read
let ctx = IdentityContext::new(user, session, roles, vec![]);
assert!(ctx.has_permission("users:read"));
assert!(ctx.has_permission("orders:read"));
assert!(!ctx.has_permission("users:write"));
assert!(!ctx.has_permission("orders:delete"));
}
// Test 34: AuthGuard assign_scope_to_role wires scope into context
#[test]
fn test_auth_guard_assign_scope_to_role() {
let (_client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
let scope = make_scope_profile();
guard.register_user(&user).unwrap();
guard.assign_role(&user.id.to_string(), &role).unwrap();
guard.assign_scope_to_role(&role, &scope).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(ctx.has_scope("profile"));
}
// Test 35: Two different users have independent sessions
#[test]
fn test_independent_user_sessions() {
let (client, sm, guard) = setup_auth_guard();
let alice = User::new("alice@example.com", "Alice");
let bob = User::new("bob@example.com", "Bob");
let admin_role = make_role_admin();
client.create_node(NODE_USER, alice.to_value()).unwrap();
client.create_node(NODE_USER, bob.to_value()).unwrap();
client.create_node(NODE_ROLE, admin_role.to_value()).unwrap();
// Only Alice gets the admin role
client.create_edge(&alice.id.to_string(), &admin_role.id.to_string(), EDGE_HAS_ROLE).unwrap();
let alice_session = sm.create(alice.id, None).unwrap();
let bob_session = sm.create(bob.id, None).unwrap();
let alice_ctx = guard.authenticate(&alice_session.id.to_string()).unwrap();
let bob_ctx = guard.authenticate(&bob_session.id.to_string()).unwrap();
assert!(alice_ctx.has_role("admin"), "Alice should have admin");
assert!(!bob_ctx.has_role("admin"), "Bob should not have admin");
assert_eq!(alice_ctx.email(), "alice@example.com");
assert_eq!(bob_ctx.email(), "bob@example.com");
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "el-layout"
version = "0.1.0"
edition = "2021"
description = "el-ui responsive layout engine — responsive by default, zero breakpoints"
license = "MIT"
[lib]
name = "el_layout"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
el-style = { path = "../el-style" }
[dev-dependencies]
+21
View File
@@ -0,0 +1,21 @@
// el-layout Responsive layout engine for el-ui.
//
// Responsive by default. VStack and HStack wrap automatically.
// Grid uses auto columns. You don't write breakpoints for basic layouts.
vessel "el-layout" {
version "0.1.0"
description "Stacks, grids, breakpoints, responsive values, safe-area insets"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-style "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+98
View File
@@ -0,0 +1,98 @@
/// Breakpoints — named viewport width thresholds.
///
/// These are provided for the rare cases where you need explicit breakpoint
/// logic. For most cases, use the automatic layout in VStack/HStack/Grid.
/// Named breakpoint sizes (in dp/logical pixels).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Breakpoint {
/// Default — any width (the base / mobile-first value).
Base,
/// Small — 640dp+ (large phones, landscape phones).
Sm,
/// Medium — 768dp+ (tablets, small laptops).
Md,
/// Large — 1024dp+ (laptops, most desktops).
Lg,
/// Extra large — 1280dp+ (large desktops, wide monitors).
Xl,
}
impl Breakpoint {
/// The minimum width (dp) at which this breakpoint activates.
pub fn min_width(&self) -> f32 {
match self {
Breakpoint::Base => 0.0,
Breakpoint::Sm => 640.0,
Breakpoint::Md => 768.0,
Breakpoint::Lg => 1024.0,
Breakpoint::Xl => 1280.0,
}
}
/// Classify a container width into its active breakpoint.
pub fn for_width(width: f32) -> Self {
if width >= 1280.0 {
Breakpoint::Xl
} else if width >= 1024.0 {
Breakpoint::Lg
} else if width >= 768.0 {
Breakpoint::Md
} else if width >= 640.0 {
Breakpoint::Sm
} else {
Breakpoint::Base
}
}
/// True if this breakpoint is at least as wide as `other`.
pub fn at_least(&self, other: Breakpoint) -> bool {
self >= &other
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn breakpoint_for_small_width() {
assert_eq!(Breakpoint::for_width(320.0), Breakpoint::Base);
}
#[test]
fn breakpoint_for_tablet_width() {
assert_eq!(Breakpoint::for_width(800.0), Breakpoint::Md);
}
#[test]
fn breakpoint_for_desktop_width() {
assert_eq!(Breakpoint::for_width(1440.0), Breakpoint::Xl);
}
#[test]
fn breakpoint_ordering() {
assert!(Breakpoint::Xl > Breakpoint::Base);
assert!(Breakpoint::Lg > Breakpoint::Sm);
}
#[test]
fn at_least() {
assert!(Breakpoint::Lg.at_least(Breakpoint::Md));
assert!(!Breakpoint::Sm.at_least(Breakpoint::Md));
}
#[test]
fn min_widths_ascending() {
let bps = [
Breakpoint::Base,
Breakpoint::Sm,
Breakpoint::Md,
Breakpoint::Lg,
Breakpoint::Xl,
];
for i in 1..bps.len() {
assert!(bps[i].min_width() > bps[i - 1].min_width());
}
}
}
+162
View File
@@ -0,0 +1,162 @@
/// Layout constraints — what the parent is offering the child.
///
/// A parent passes a LayoutConstraints to each child during layout.
/// The child must produce a size within these constraints.
/// Constraints flow down; sizes flow back up.
/// Constraints passed from a parent to a child during layout.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutConstraints {
/// Minimum width the child must be (dp).
pub min_width: f32,
/// Maximum width available to the child (dp). f32::INFINITY = unbounded.
pub max_width: f32,
/// Minimum height the child must be (dp).
pub min_height: f32,
/// Maximum height available to the child (dp). f32::INFINITY = unbounded.
pub max_height: f32,
}
impl LayoutConstraints {
/// Unconstrained — the child can be any size.
pub fn unbounded() -> Self {
Self {
min_width: 0.0,
max_width: f32::INFINITY,
min_height: 0.0,
max_height: f32::INFINITY,
}
}
/// Constrained to a specific width, unconstrained height.
pub fn with_max_width(max_width: f32) -> Self {
Self {
min_width: 0.0,
max_width,
min_height: 0.0,
max_height: f32::INFINITY,
}
}
/// Constrained to an exact width and height.
pub fn tight(width: f32, height: f32) -> Self {
Self {
min_width: width,
max_width: width,
min_height: height,
max_height: height,
}
}
/// Return constraints loosened to allow any size up to the maximums.
pub fn loosen(&self) -> Self {
Self {
min_width: 0.0,
max_width: self.max_width,
min_height: 0.0,
max_height: self.max_height,
}
}
/// Deflate the constraints by padding amounts.
/// Useful when a parent applies its own padding before offering space to a child.
pub fn deflate(&self, horizontal: f32, vertical: f32) -> Self {
Self {
min_width: (self.min_width - horizontal).max(0.0),
max_width: (self.max_width - horizontal).max(0.0),
min_height: (self.min_height - vertical).max(0.0),
max_height: (self.max_height - vertical).max(0.0),
}
}
/// Is the width dimension bounded?
pub fn has_bounded_width(&self) -> bool {
self.max_width.is_finite()
}
/// Is the height dimension bounded?
pub fn has_bounded_height(&self) -> bool {
self.max_height.is_finite()
}
/// Clamp a proposed size to fit within these constraints.
pub fn clamp_size(&self, width: f32, height: f32) -> (f32, f32) {
(
width.clamp(self.min_width, self.max_width),
height.clamp(self.min_height, self.max_height),
)
}
}
/// The size a child reports back to its parent after layout.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Size {
pub width: f32,
pub height: f32,
}
impl Size {
pub fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
pub fn zero() -> Self {
Self { width: 0.0, height: 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unbounded_has_no_max() {
let c = LayoutConstraints::unbounded();
assert!(c.max_width.is_infinite());
assert!(c.max_height.is_infinite());
}
#[test]
fn tight_constraints() {
let c = LayoutConstraints::tight(100.0, 50.0);
assert_eq!(c.min_width, 100.0);
assert_eq!(c.max_width, 100.0);
}
#[test]
fn deflate_reduces_available_space() {
let c = LayoutConstraints::tight(200.0, 100.0);
let deflated = c.deflate(16.0, 8.0);
assert_eq!(deflated.max_width, 184.0);
assert_eq!(deflated.max_height, 92.0);
}
#[test]
fn deflate_does_not_go_negative() {
let c = LayoutConstraints::tight(10.0, 10.0);
let deflated = c.deflate(20.0, 20.0);
assert_eq!(deflated.max_width, 0.0);
assert_eq!(deflated.max_height, 0.0);
}
#[test]
fn clamp_size() {
let c = LayoutConstraints {
min_width: 50.0,
max_width: 200.0,
min_height: 30.0,
max_height: 100.0,
};
let (w, h) = c.clamp_size(250.0, 20.0);
assert_eq!(w, 200.0);
assert_eq!(h, 30.0);
}
#[test]
fn bounded_width_detection() {
let bounded = LayoutConstraints::with_max_width(400.0);
let unbounded = LayoutConstraints::unbounded();
assert!(bounded.has_bounded_width());
assert!(!unbounded.has_bounded_width());
}
}
+178
View File
@@ -0,0 +1,178 @@
/// FlexLayout — the underlying flex engine for VStack, HStack.
///
/// This is the power behind the stacks. Most developers use VStack/HStack
/// directly; FlexLayout is available when you need full control.
/// Direction of the flex axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlexDirection {
/// Children arranged top-to-bottom (VStack).
Column,
/// Children arranged left-to-right (HStack) — respects RTL automatically.
Row,
/// Like Column, but children wrap to new columns when they overflow.
ColumnWrap,
/// Like Row, but children wrap to new rows when they overflow.
RowWrap,
}
impl FlexDirection {
pub fn is_horizontal(&self) -> bool {
matches!(self, FlexDirection::Row | FlexDirection::RowWrap)
}
pub fn is_vertical(&self) -> bool {
matches!(self, FlexDirection::Column | FlexDirection::ColumnWrap)
}
pub fn wraps(&self) -> bool {
matches!(
self,
FlexDirection::ColumnWrap | FlexDirection::RowWrap
)
}
}
/// Alignment along the cross axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossAxisAlignment {
/// Stretch children to fill the cross axis.
Stretch,
/// Align children to the start of the cross axis.
Start,
/// Center children on the cross axis.
Center,
/// Align children to the end of the cross axis.
End,
/// Align text baselines (horizontal stacks only).
Baseline,
}
/// Alignment along the main axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MainAxisAlignment {
/// Pack children toward the start.
Start,
/// Center children.
Center,
/// Pack children toward the end.
End,
/// Distribute space between children.
SpaceBetween,
/// Distribute space around children.
SpaceAround,
/// Distribute space evenly around children.
SpaceEvenly,
}
/// How much space the main axis should occupy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MainAxisSize {
/// Shrink to minimum size needed.
Min,
/// Expand to fill all available space.
Max,
}
/// Logical horizontal alignment (RTL-aware).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HAlign {
/// Toward the reading-start (left in LTR, right in RTL).
Leading,
Center,
/// Toward the reading-end (right in LTR, left in RTL).
Trailing,
}
/// Vertical alignment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
Baseline,
}
/// Full flex layout specification.
#[derive(Debug, Clone)]
pub struct FlexLayout {
pub direction: FlexDirection,
pub main_axis_alignment: MainAxisAlignment,
pub cross_axis_alignment: CrossAxisAlignment,
pub main_axis_size: MainAxisSize,
/// Gap between children in dp.
pub gap: u32,
}
impl FlexLayout {
/// VStack defaults (column, wrapping, leading-aligned).
pub fn vstack(spacing: u32, wrap: bool) -> Self {
Self {
direction: if wrap {
FlexDirection::ColumnWrap
} else {
FlexDirection::Column
},
main_axis_alignment: MainAxisAlignment::Start,
cross_axis_alignment: CrossAxisAlignment::Stretch,
main_axis_size: MainAxisSize::Max,
gap: spacing,
}
}
/// HStack defaults (row, wrapping, center-aligned vertically).
pub fn hstack(spacing: u32, wrap: bool) -> Self {
Self {
direction: if wrap {
FlexDirection::RowWrap
} else {
FlexDirection::Row
},
main_axis_alignment: MainAxisAlignment::Start,
cross_axis_alignment: CrossAxisAlignment::Center,
main_axis_size: MainAxisSize::Max,
gap: spacing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flex_direction_horizontal() {
assert!(FlexDirection::Row.is_horizontal());
assert!(FlexDirection::RowWrap.is_horizontal());
assert!(!FlexDirection::Column.is_horizontal());
}
#[test]
fn flex_direction_vertical() {
assert!(FlexDirection::Column.is_vertical());
assert!(!FlexDirection::Row.is_vertical());
}
#[test]
fn flex_direction_wraps() {
assert!(FlexDirection::RowWrap.wraps());
assert!(!FlexDirection::Row.wraps());
}
#[test]
fn vstack_layout_defaults() {
let layout = FlexLayout::vstack(8, true);
assert!(layout.direction.is_vertical());
assert!(layout.direction.wraps());
assert_eq!(layout.gap, 8);
}
#[test]
fn hstack_layout_defaults() {
let layout = FlexLayout::hstack(16, false);
assert!(layout.direction.is_horizontal());
assert!(!layout.direction.wraps());
assert_eq!(layout.gap, 16);
assert_eq!(layout.cross_axis_alignment, CrossAxisAlignment::Center);
}
}
+187
View File
@@ -0,0 +1,187 @@
/// GridLayout — a responsive column grid.
///
/// The auto column mode (GridColumns::Auto) automatically computes how many
/// columns fit given a minimum column width. No breakpoints needed.
/// Fixed column count (GridColumns::Fixed) puts exactly N columns in a row.
use el_style::modifier::{StyleModifier, StyleSet};
/// How to determine the number of grid columns.
#[derive(Debug, Clone, PartialEq)]
pub enum GridColumns {
/// A fixed number of equally-wide columns.
Fixed(u32),
/// As many columns as fit with each column at least `min_width` dp wide.
/// This is how you get responsive grids without breakpoints.
Auto { min_width: f32 },
}
impl GridColumns {
/// Compute the actual column count given a container width.
pub fn count_for_width(&self, container_width: f32) -> u32 {
match self {
GridColumns::Fixed(n) => *n,
GridColumns::Auto { min_width } => {
if container_width <= 0.0 || *min_width <= 0.0 {
return 1;
}
let cols = (container_width / min_width).floor() as u32;
cols.max(1)
}
}
}
/// Compute the width of each column given container width and gap.
pub fn column_width(&self, container_width: f32, gap: f32) -> f32 {
let cols = self.count_for_width(container_width) as f32;
let total_gap = gap * (cols - 1.0).max(0.0);
((container_width - total_gap) / cols).max(0.0)
}
}
/// Row height specification.
#[derive(Debug, Clone, PartialEq)]
pub enum GridRows {
/// All rows are the same height (dp).
Fixed(f32),
/// Rows take the height of their tallest item.
Auto,
}
/// A responsive grid layout.
#[derive(Debug, Clone)]
pub struct GridLayout {
pub columns: GridColumns,
pub rows: GridRows,
/// Horizontal gap between columns (dp).
pub column_gap: u32,
/// Vertical gap between rows (dp).
pub row_gap: u32,
pub style: StyleSet,
}
impl Default for GridLayout {
fn default() -> Self {
Self {
columns: GridColumns::Auto { min_width: 200.0 },
rows: GridRows::Auto,
column_gap: 16,
row_gap: 16,
style: StyleSet::default(),
}
}
}
impl GridLayout {
pub fn new() -> Self {
Self::default()
}
/// Set a fixed column count.
pub fn columns_fixed(mut self, n: u32) -> Self {
self.columns = GridColumns::Fixed(n);
self
}
/// Set auto columns with a minimum column width.
pub fn columns_auto(mut self, min_width: f32) -> Self {
self.columns = GridColumns::Auto { min_width };
self
}
/// Set gap (same for both axes).
pub fn gap(mut self, dp: u32) -> Self {
self.column_gap = dp;
self.row_gap = dp;
self
}
/// Set column and row gaps separately.
pub fn gap_xy(mut self, column_gap: u32, row_gap: u32) -> Self {
self.column_gap = column_gap;
self.row_gap = row_gap;
self
}
/// How many columns are active at a given container width?
pub fn active_columns(&self, container_width: f32) -> u32 {
self.columns.count_for_width(container_width)
}
}
impl StyleModifier for GridLayout {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_columns_always_returns_n() {
let cols = GridColumns::Fixed(3);
assert_eq!(cols.count_for_width(100.0), 3);
assert_eq!(cols.count_for_width(2000.0), 3);
}
#[test]
fn auto_columns_small_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(300.0), 1);
}
#[test]
fn auto_columns_medium_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(500.0), 2);
}
#[test]
fn auto_columns_wide_container() {
let cols = GridColumns::Auto { min_width: 200.0 };
assert_eq!(cols.count_for_width(1200.0), 6);
}
#[test]
fn auto_columns_minimum_one() {
let cols = GridColumns::Auto { min_width: 500.0 };
assert_eq!(cols.count_for_width(100.0), 1);
}
#[test]
fn column_width_with_gap() {
let cols = GridColumns::Fixed(3);
// 300px wide, 3 cols, 16px gap between each = 2 gaps
// (300 - 32) / 3 = 268/3 ≈ 89.33
let w = cols.column_width(300.0, 16.0);
assert!((w - (300.0 - 32.0) / 3.0).abs() < 0.01);
}
#[test]
fn grid_defaults() {
let grid = GridLayout::new();
assert_eq!(grid.column_gap, 16);
assert_eq!(grid.row_gap, 16);
}
#[test]
fn grid_active_columns_auto() {
let grid = GridLayout::new().columns_auto(200.0);
assert_eq!(grid.active_columns(600.0), 3);
}
#[test]
fn grid_active_columns_fixed() {
let grid = GridLayout::new().columns_fixed(4);
assert_eq!(grid.active_columns(100.0), 4);
}
#[test]
fn grid_gap_xy() {
let grid = GridLayout::new().gap_xy(8, 24);
assert_eq!(grid.column_gap, 8);
assert_eq!(grid.row_gap, 24);
}
}
+50
View File
@@ -0,0 +1,50 @@
//! el-layout — Responsive layout engine for el-ui.
//!
//! **Responsive by default.** VStack and HStack wrap automatically.
//! Grid uses auto columns. You don't write breakpoints for basic layouts.
//!
//! ## Layout primitives
//!
//! - [`VStack`] — vertical stack, wraps by default
//! - [`HStack`] — horizontal stack, wraps by default, RTL-aware
//! - [`ZStack`] — depth stack, children overlap
//! - [`GridLayout`] — responsive grid, auto or fixed columns
//! - [`ScrollView`] — scrollable container
//!
//! ## Responsive values
//!
//! For the rare case where you need a value to change at a specific breakpoint:
//! ```
//! use el_layout::prelude::*;
//!
//! // Most specific value that applies cascades down to less specific
//! let cols: Responsive<u32> = Responsive::fixed(1).md(2).lg(3);
//! assert_eq!(*cols.resolve(Breakpoint::Sm), 1);
//! assert_eq!(*cols.resolve(Breakpoint::Md), 2);
//! assert_eq!(*cols.resolve(Breakpoint::Lg), 3);
//! assert_eq!(*cols.resolve(Breakpoint::Xl), 3); // cascades from lg
//! ```
#![deny(warnings)]
pub mod breakpoint;
pub mod constraints;
pub mod flex;
pub mod grid;
pub mod platform;
pub mod responsive;
pub mod scroll;
pub mod stack;
pub mod prelude {
pub use crate::breakpoint::Breakpoint;
pub use crate::constraints::{LayoutConstraints, Size};
pub use crate::flex::{CrossAxisAlignment, FlexDirection, FlexLayout, HAlign, MainAxisAlignment, MainAxisSize, VAlign};
pub use crate::grid::{GridColumns, GridLayout, GridRows};
pub use crate::platform::{PlatformFamily, PlatformSizing, SafeAreaInsets};
pub use crate::responsive::Responsive;
pub use crate::scroll::{ScrollAxis, ScrollIndicator, ScrollView};
pub use crate::stack::{HStack, Spacer, VStack, ZStack};
}
pub use prelude::*;
+318
View File
@@ -0,0 +1,318 @@
// el-layout Responsive layout engine for el-ui.
//
// Primitives:
// VStack, HStack, ZStack stack layouts (wrap by default)
// GridLayout responsive grid
// ScrollView scrollable container
// Responsive<T> value that changes by breakpoint
// Breakpoints
let BP_XS: String = "xs" // < 640px
let BP_SM: String = "sm" // >= 640px
let BP_MD: String = "md" // >= 768px
let BP_LG: String = "lg" // >= 1024px
let BP_XL: String = "xl" // >= 1280px
let BP_XXL: String = "xxl" // >= 1536px
let BP_SM_PX: Int = 640
let BP_MD_PX: Int = 768
let BP_LG_PX: Int = 1024
let BP_XL_PX: Int = 1280
let BP_XXL_PX: Int = 1536
fn breakpoint_for_width(width_px: Int) -> String {
if width_px >= BP_XXL_PX { return BP_XXL }
if width_px >= BP_XL_PX { return BP_XL }
if width_px >= BP_LG_PX { return BP_LG }
if width_px >= BP_MD_PX { return BP_MD }
if width_px >= BP_SM_PX { return BP_SM }
BP_XS
}
// Cascade index used by Responsive<T> to find the most-specific value <= bp.
fn breakpoint_index(bp: String) -> Int {
if str_eq(bp, "xs") { return 0 }
if str_eq(bp, "sm") { return 1 }
if str_eq(bp, "md") { return 2 }
if str_eq(bp, "lg") { return 3 }
if str_eq(bp, "xl") { return 4 }
if str_eq(bp, "xxl") { return 5 }
0
}
// Responsive<T>
//
// Stored as a JSON object: { "xs": v0, "md": v1, "lg": v2 }
// resolve(bp) walks down from bp until it finds a defined value.
fn responsive_fixed(value: String) -> String {
"{\"xs\":" + value + "}"
}
fn responsive_set(rv: String, bp: String, value: String) -> String {
json_set(rv, bp, value)
}
fn responsive_resolve(rv: String, bp: String) -> String {
let order: String = "xxl,xl,lg,md,sm,xs"
let target_idx: Int = breakpoint_index(bp)
// Try each breakpoint <= target, most-specific first.
let probe: String = bp
while !str_eq(probe, "") {
let v: String = json_get(rv, probe)
if !str_eq(v, "") { return v }
let idx: Int = breakpoint_index(probe)
if idx == 0 { return "" }
let probe = breakpoint_step_down(probe)
}
""
}
fn breakpoint_step_down(bp: String) -> String {
if str_eq(bp, "xxl") { return "xl" }
if str_eq(bp, "xl") { return "lg" }
if str_eq(bp, "lg") { return "md" }
if str_eq(bp, "md") { return "sm" }
if str_eq(bp, "sm") { return "xs" }
""
}
// Constraints / Size
type Size {
width: Int
height: Int
}
type LayoutConstraints {
min_width: Int
max_width: Int
min_height: Int
max_height: Int
}
fn constraints_unbounded() -> LayoutConstraints {
{ "min_width": 0, "max_width": 999999, "min_height": 0, "max_height": 999999 }
}
fn constraints_tight(w: Int, h: Int) -> LayoutConstraints {
{ "min_width": w, "max_width": w, "min_height": h, "max_height": h }
}
// Flex axes
let FLEX_ROW: String = "row"
let FLEX_COLUMN: String = "column"
let MAIN_START: String = "start"
let MAIN_END: String = "end"
let MAIN_CENTER: String = "center"
let MAIN_BETWEEN: String = "space-between"
let MAIN_AROUND: String = "space-around"
let MAIN_EVENLY: String = "space-evenly"
let CROSS_START: String = "start"
let CROSS_END: String = "end"
let CROSS_CENTER: String = "center"
let CROSS_STRETCH: String = "stretch"
let CROSS_BASELINE: String = "baseline"
type FlexLayout {
direction: String
main_alignment: String
cross_alignment: String
gap_px: Int
wrap: Bool
}
fn flex_default() -> FlexLayout {
{ "direction": "row", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": 8, "wrap": true }
}
// Stacks
//
// VStack / HStack / ZStack are component classes in the JS runtime; the
// El side here exposes their layout descriptor the bag of values the
// el-ui-compiler emits into JSX/HTML attributes.
type StackLayout {
direction: String // row | column | depth
main_alignment: String
cross_alignment: String
gap_px: Int
wrap: Bool
spacing_token: String // semantic spacing token (md, lg, ...)
}
fn vstack(spacing_token: String) -> StackLayout {
{ "direction": "column", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": 0, "wrap": true,
"spacing_token": spacing_token }
}
fn hstack(spacing_token: String) -> StackLayout {
{ "direction": "row", "main_alignment": "start",
"cross_alignment": "center", "gap_px": 0, "wrap": true,
"spacing_token": spacing_token }
}
fn zstack() -> StackLayout {
{ "direction": "depth", "main_alignment": "center",
"cross_alignment": "center", "gap_px": 0, "wrap": false,
"spacing_token": "" }
}
// Grid
type GridLayout {
columns: String // "auto" or "1fr 1fr 1fr" etc
rows: String
gap_px: Int
auto_fit_min: Int // for `repeat(auto-fit, minmax(<min>, 1fr))`
}
fn grid_auto(min_col_px: Int, gap: Int) -> GridLayout {
{ "columns": "auto-fit", "rows": "auto", "gap_px": gap, "auto_fit_min": min_col_px }
}
fn grid_fixed(num_cols: Int, gap: Int) -> GridLayout {
let cols: String = repeat_str("1fr ", num_cols)
{ "columns": cols, "rows": "auto", "gap_px": gap, "auto_fit_min": 0 }
}
fn grid_to_css(g: GridLayout) -> String {
let cols: String = g.columns
if str_eq(cols, "auto-fit") {
let cols = "repeat(auto-fit, minmax(" + int_to_str(g.auto_fit_min) + "px, 1fr))"
}
"display: grid; grid-template-columns: " + cols + "; gap: " + int_to_str(g.gap_px) + "px;"
}
// ScrollView
let SCROLL_X: String = "x"
let SCROLL_Y: String = "y"
let SCROLL_BOTH: String = "both"
type ScrollView {
axis: String
show_indicator: Bool
bounce: Bool // iOS-style overscroll
}
fn scroll_view_y() -> ScrollView {
{ "axis": "y", "show_indicator": true, "bounce": true }
}
// Platform sizing
let PLATFORM_PHONE: String = "phone"
let PLATFORM_TABLET: String = "tablet"
let PLATFORM_DESKTOP: String = "desktop"
type SafeAreaInsets {
top: Int
right: Int
bottom: Int
left: Int
}
fn safe_area_zero() -> SafeAreaInsets {
{ "top": 0, "right": 0, "bottom": 0, "left": 0 }
}
fn platform_for_width(width: Int) -> String {
if width < 768 { return "phone" }
if width < 1024 { return "tablet" }
"desktop"
}
// HTML emit
//
// Server-side render: layout descriptor HTML string.
// Children is a pre-rendered HTML string passed in by the caller.
// class_extra is an optional additional CSS class string (pass "" for none).
fn stack_direction_to_css(direction: String) -> String {
if str_eq(direction, "column") { return "column" }
if str_eq(direction, "depth") { return "unset" }
"row"
}
fn stack_align_to_css(align: String) -> String {
if str_eq(align, "center") { return "center" }
if str_eq(align, "end") { return "flex-end" }
if str_eq(align, "baseline") { return "baseline" }
if str_eq(align, "stretch") { return "stretch" }
"flex-start"
}
fn stack_justify_to_css(align: String) -> String {
if str_eq(align, "center") { return "center" }
if str_eq(align, "end") { return "flex-end" }
if str_eq(align, "space-between") { return "space-between" }
if str_eq(align, "space-around") { return "space-around" }
if str_eq(align, "space-evenly") { return "space-evenly" }
"flex-start"
}
fn stack_to_html(layout: StackLayout, children: String, class_extra: String) -> String {
let direction: String = stack_direction_to_css(layout.direction)
let justify: String = stack_justify_to_css(layout.main_alignment)
let align: String = stack_align_to_css(layout.cross_alignment)
let gap: String = int_to_str(layout.gap_px) + "px"
let wrap_val: String = if layout.wrap { "wrap" } else { "nowrap" }
let style: String = "display:flex;flex-direction:" + direction
+ ";justify-content:" + justify
+ ";align-items:" + align
+ ";gap:" + gap
+ ";flex-wrap:" + wrap_val
let base_class: String = "el-stack el-stack--" + layout.direction
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + style + "\">" + children + "</div>"
}
fn vstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
let layout: StackLayout = { "direction": "column", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
stack_to_html(layout, children, class_extra)
}
fn hstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String {
let layout: StackLayout = { "direction": "row", "main_alignment": "start",
"cross_alignment": "center", "gap_px": spacing_px, "wrap": true, "spacing_token": "" }
stack_to_html(layout, children, class_extra)
}
fn zstack_to_html(children: String, class_extra: String) -> String {
let base_class: String = "el-stack el-stack--depth"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"display:grid;place-items:center\">" + children + "</div>"
}
fn grid_to_html(layout: GridLayout, children: String, class_extra: String) -> String {
let css: String = grid_to_css(layout)
let base_class: String = "el-grid"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + css + "\">" + children + "</div>"
}
fn scroll_to_html(layout: ScrollView, children: String, class_extra: String) -> String {
let overflow: String = if str_eq(layout.axis, "x") { "overflow-x:auto;overflow-y:hidden" }
else if str_eq(layout.axis, "both") { "overflow:auto" }
else { "overflow-x:hidden;overflow-y:auto" }
let base_class: String = "el-scroll"
let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra }
"<div class=\"" + cls + "\" style=\"" + overflow + "\">" + children + "</div>"
}
// Entry smoke test
let r: String = responsive_fixed("1")
let r = responsive_set(r, "md", "2")
let r = responsive_set(r, "lg", "3")
println("[el-layout] cols at lg = " + responsive_resolve(r, "lg"))
let v: String = vstack_to_html(16, "<p>hello</p>", "")
println("[el-layout] vstack = " + v)
+197
View File
@@ -0,0 +1,197 @@
/// Platform-aware sizing constants.
///
/// Platform HIG minimum touch targets, safe area handling, and
/// density-independent pixel conventions.
/// Which platform family the app is running on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformFamily {
/// iOS / iPadOS
Ios,
/// Android
Android,
/// macOS
Macos,
/// Windows
Windows,
/// Linux desktop
Linux,
/// Web browser
Web,
}
/// Platform-specific sizing constants.
#[derive(Debug, Clone)]
pub struct PlatformSizing {
/// Minimum touch target dimension in dp (height and width).
pub min_touch_target: f32,
/// Standard icon size in dp.
pub icon_size: f32,
/// Standard small icon size in dp.
pub icon_size_sm: f32,
/// Standard navigation bar height in dp.
pub nav_bar_height: f32,
/// Standard tab bar height in dp.
pub tab_bar_height: f32,
/// Standard status bar height in dp (approximate; actual is platform-provided).
pub status_bar_height: f32,
}
impl PlatformSizing {
/// Sizing constants for a given platform.
pub fn for_platform(platform: PlatformFamily) -> Self {
match platform {
PlatformFamily::Ios => Self {
min_touch_target: 44.0, // Apple HIG
icon_size: 24.0,
icon_size_sm: 16.0,
nav_bar_height: 44.0,
tab_bar_height: 49.0,
status_bar_height: 44.0, // approximate; varies with notch
},
PlatformFamily::Android => Self {
min_touch_target: 48.0, // Material Design
icon_size: 24.0,
icon_size_sm: 18.0,
nav_bar_height: 56.0,
tab_bar_height: 56.0,
status_bar_height: 24.0,
},
PlatformFamily::Macos => Self {
min_touch_target: 44.0, // macOS HIG
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 28.0,
tab_bar_height: 36.0,
status_bar_height: 0.0, // macOS status bar is system chrome
},
PlatformFamily::Windows => Self {
min_touch_target: 44.0,
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 40.0,
tab_bar_height: 40.0,
status_bar_height: 0.0,
},
PlatformFamily::Linux => Self {
min_touch_target: 44.0,
icon_size: 16.0,
icon_size_sm: 12.0,
nav_bar_height: 36.0,
tab_bar_height: 36.0,
status_bar_height: 0.0,
},
PlatformFamily::Web => Self {
min_touch_target: 44.0, // WCAG 2.5.5 recommended
icon_size: 20.0,
icon_size_sm: 16.0,
nav_bar_height: 64.0,
tab_bar_height: 48.0,
status_bar_height: 0.0,
},
}
}
/// Is the given width adequate for a touch target?
pub fn is_touch_adequate(&self, width: f32, height: f32) -> bool {
width >= self.min_touch_target && height >= self.min_touch_target
}
}
/// Safe area insets — space reserved by system chrome.
///
/// These are provided at runtime by the platform. The values here
/// are conservative defaults for simulation.
#[derive(Debug, Clone, Copy, Default)]
pub struct SafeAreaInsets {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
impl SafeAreaInsets {
/// No safe area insets (desktop platforms).
pub fn none() -> Self {
Self::default()
}
/// Typical iPhone safe area (notch at top, home indicator at bottom).
pub fn iphone_notch() -> Self {
Self {
top: 44.0,
right: 0.0,
bottom: 34.0,
left: 0.0,
}
}
/// Dynamic island (iPhone 14 Pro+).
pub fn iphone_dynamic_island() -> Self {
Self {
top: 59.0,
right: 0.0,
bottom: 34.0,
left: 0.0,
}
}
/// Total horizontal safe area.
pub fn horizontal(&self) -> f32 {
self.left + self.right
}
/// Total vertical safe area.
pub fn vertical(&self) -> f32 {
self.top + self.bottom
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_min_touch_target() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
assert_eq!(sizing.min_touch_target, 44.0);
}
#[test]
fn android_min_touch_target() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Android);
assert_eq!(sizing.min_touch_target, 48.0);
}
#[test]
fn touch_adequate_check() {
let sizing = PlatformSizing::for_platform(PlatformFamily::Ios);
assert!(sizing.is_touch_adequate(44.0, 44.0));
assert!(!sizing.is_touch_adequate(40.0, 44.0));
assert!(!sizing.is_touch_adequate(44.0, 40.0));
}
#[test]
fn safe_area_horizontal() {
let sa = SafeAreaInsets {
top: 44.0,
right: 16.0,
bottom: 34.0,
left: 16.0,
};
assert_eq!(sa.horizontal(), 32.0);
}
#[test]
fn safe_area_vertical() {
let sa = SafeAreaInsets::iphone_notch();
assert_eq!(sa.vertical(), 78.0);
}
#[test]
fn no_safe_area_is_zero() {
let sa = SafeAreaInsets::none();
assert_eq!(sa.horizontal(), 0.0);
assert_eq!(sa.vertical(), 0.0);
}
}
+154
View File
@@ -0,0 +1,154 @@
/// Responsive<T> — a value that varies by breakpoint.
///
/// The base value is always required (mobile-first). `sm`, `md`, `lg`, `xl`
/// are all optional — if not set, the nearest smaller value is used.
///
/// You generally don't need Responsive<T> for layout — VStack and Grid handle
/// reflow automatically. Use Responsive<T> when you need to vary a non-layout
/// value (e.g. font size, column count, visibility) at specific breakpoints.
use crate::breakpoint::Breakpoint;
/// A value that varies by viewport breakpoint.
///
/// Follows mobile-first cascade: base < sm < md < lg < xl.
/// If a breakpoint value is not set, it inherits from the next smaller one.
#[derive(Debug, Clone, PartialEq)]
pub struct Responsive<T: Clone> {
/// Mobile-first base value. Always required.
pub base: T,
/// 640dp+. If None, uses `base`.
pub sm: Option<T>,
/// 768dp+. If None, uses `sm` or `base`.
pub md: Option<T>,
/// 1024dp+. If None, uses `md`, `sm`, or `base`.
pub lg: Option<T>,
/// 1280dp+. If None, uses `lg`, `md`, `sm`, or `base`.
pub xl: Option<T>,
}
impl<T: Clone> Responsive<T> {
/// Create a responsive value with only the base (same on all screen sizes).
pub fn fixed(value: T) -> Self {
Self {
base: value,
sm: None,
md: None,
lg: None,
xl: None,
}
}
/// Create a fully-specified responsive value.
pub fn new(
base: T,
sm: Option<T>,
md: Option<T>,
lg: Option<T>,
xl: Option<T>,
) -> Self {
Self { base, sm, md, lg, xl }
}
/// Resolve to the most-specific value that applies at the given breakpoint.
///
/// Cascades downward: xl → lg → md → sm → base.
pub fn resolve(&self, breakpoint: Breakpoint) -> &T {
match breakpoint {
Breakpoint::Xl => {
self.xl.as_ref()
.or(self.lg.as_ref())
.or(self.md.as_ref())
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Lg => {
self.lg.as_ref()
.or(self.md.as_ref())
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Md => {
self.md.as_ref()
.or(self.sm.as_ref())
.unwrap_or(&self.base)
}
Breakpoint::Sm => {
self.sm.as_ref().unwrap_or(&self.base)
}
Breakpoint::Base => &self.base,
}
}
/// Set the sm value (builder pattern).
pub fn sm(mut self, value: T) -> Self {
self.sm = Some(value);
self
}
/// Set the md value.
pub fn md(mut self, value: T) -> Self {
self.md = Some(value);
self
}
/// Set the lg value.
pub fn lg(mut self, value: T) -> Self {
self.lg = Some(value);
self
}
/// Set the xl value.
pub fn xl(mut self, value: T) -> Self {
self.xl = Some(value);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_always_returns_base() {
let r = Responsive::fixed(42u32);
assert_eq!(*r.resolve(Breakpoint::Base), 42);
assert_eq!(*r.resolve(Breakpoint::Xl), 42);
}
#[test]
fn resolves_most_specific() {
let r = Responsive::fixed(1u32).sm(2).md(3).lg(4).xl(5);
assert_eq!(*r.resolve(Breakpoint::Base), 1);
assert_eq!(*r.resolve(Breakpoint::Sm), 2);
assert_eq!(*r.resolve(Breakpoint::Md), 3);
assert_eq!(*r.resolve(Breakpoint::Lg), 4);
assert_eq!(*r.resolve(Breakpoint::Xl), 5);
}
#[test]
fn cascades_down_when_specific_missing() {
let r = Responsive::fixed(1u32).md(3);
// sm not set → falls back to base
assert_eq!(*r.resolve(Breakpoint::Sm), 1);
// lg not set → falls back to md
assert_eq!(*r.resolve(Breakpoint::Lg), 3);
// xl not set → falls back to md (lg not set either)
assert_eq!(*r.resolve(Breakpoint::Xl), 3);
}
#[test]
fn cascade_xl_to_lg_to_sm_to_base() {
let r = Responsive::fixed("base").sm("sm");
assert_eq!(*r.resolve(Breakpoint::Md), "sm");
assert_eq!(*r.resolve(Breakpoint::Lg), "sm");
assert_eq!(*r.resolve(Breakpoint::Xl), "sm");
}
#[test]
fn responsive_with_strings() {
let r = Responsive::fixed("mobile").lg("desktop");
assert_eq!(*r.resolve(Breakpoint::Base), "mobile");
assert_eq!(*r.resolve(Breakpoint::Lg), "desktop");
}
}
+141
View File
@@ -0,0 +1,141 @@
/// ScrollView — scrollable container.
///
/// Wraps content that may exceed the available space. Scrolling axis can be
/// vertical (default), horizontal, or both. On platforms with native scroll
/// behaviors (iOS, Android), the backend translates this to a native scroll
/// container — you get momentum, overscroll, pull-to-refresh for free.
use el_style::modifier::{StyleModifier, StyleSet};
/// Which axis (or axes) can scroll.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAxis {
/// Vertical scrolling only (default). Most content views.
Vertical,
/// Horizontal scrolling only. Carousels, horizontal lists.
Horizontal,
/// Free scrolling in both directions. Maps, canvases.
Both,
}
/// Scroll indicator visibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollIndicator {
/// Show when scrolling, hide otherwise (platform default).
Automatic,
/// Always show.
Always,
/// Never show.
Never,
}
/// A scrollable container.
#[derive(Debug, Clone)]
pub struct ScrollView {
pub axis: ScrollAxis,
pub indicator: ScrollIndicator,
/// Whether the user can zoom (pinch-to-zoom). Default: false.
pub zoomable: bool,
/// Minimum zoom scale (only relevant when zoomable = true).
pub min_zoom: f32,
/// Maximum zoom scale (only relevant when zoomable = true).
pub max_zoom: f32,
/// Whether to clip content to the scroll view bounds. Default: true.
pub clips_to_bounds: bool,
pub style: StyleSet,
}
impl Default for ScrollView {
fn default() -> Self {
Self {
axis: ScrollAxis::Vertical,
indicator: ScrollIndicator::Automatic,
zoomable: false,
min_zoom: 1.0,
max_zoom: 3.0,
clips_to_bounds: true,
style: StyleSet::default(),
}
}
}
impl ScrollView {
pub fn new() -> Self {
Self::default()
}
pub fn horizontal() -> Self {
Self {
axis: ScrollAxis::Horizontal,
..Self::default()
}
}
pub fn both_axes() -> Self {
Self {
axis: ScrollAxis::Both,
..Self::default()
}
}
pub fn indicator(mut self, indicator: ScrollIndicator) -> Self {
self.indicator = indicator;
self
}
pub fn zoomable(mut self, min: f32, max: f32) -> Self {
self.zoomable = true;
self.min_zoom = min;
self.max_zoom = max;
self
}
}
impl StyleModifier for ScrollView {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_default_is_vertical() {
let sv = ScrollView::new();
assert_eq!(sv.axis, ScrollAxis::Vertical);
}
#[test]
fn scroll_horizontal_constructor() {
let sv = ScrollView::horizontal();
assert_eq!(sv.axis, ScrollAxis::Horizontal);
}
#[test]
fn scroll_both_axes() {
let sv = ScrollView::both_axes();
assert_eq!(sv.axis, ScrollAxis::Both);
}
#[test]
fn scroll_zoomable() {
let sv = ScrollView::new().zoomable(0.5, 4.0);
assert!(sv.zoomable);
assert_eq!(sv.min_zoom, 0.5);
assert_eq!(sv.max_zoom, 4.0);
}
#[test]
fn scroll_clips_by_default() {
let sv = ScrollView::new();
assert!(sv.clips_to_bounds);
}
#[test]
fn scroll_indicator_setting() {
let sv = ScrollView::new().indicator(ScrollIndicator::Never);
assert_eq!(sv.indicator, ScrollIndicator::Never);
}
}
+286
View File
@@ -0,0 +1,286 @@
/// VStack, HStack, ZStack — the primary layout building blocks.
///
/// These are the component API. FlexLayout is the engine underneath.
/// VStack and HStack wrap automatically by default — that's what makes
/// the layout responsive without writing breakpoints.
use crate::flex::{CrossAxisAlignment, FlexLayout, HAlign, VAlign};
use el_style::modifier::{StyleModifier, StyleSet};
/// A vertical stack — children arranged from top to bottom.
///
/// Wraps automatically when height is constrained (mobile-first).
/// The default gap is 8dp. Cross-axis fills the container width.
#[derive(Debug, Clone)]
pub struct VStack {
/// Space between children in dp (default: 8).
pub spacing: u32,
/// Horizontal alignment of children (default: Leading).
pub alignment: HAlign,
/// Whether to wrap to a new column when out of vertical space (default: true).
pub wrap: bool,
pub style: StyleSet,
}
impl Default for VStack {
fn default() -> Self {
Self {
spacing: 8,
alignment: HAlign::Leading,
wrap: true,
style: StyleSet::default(),
}
}
}
impl VStack {
pub fn new() -> Self {
Self::default()
}
pub fn spacing(mut self, dp: u32) -> Self {
self.spacing = dp;
self
}
pub fn alignment(mut self, align: HAlign) -> Self {
self.alignment = align;
self
}
/// Enable or disable wrapping.
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
/// Convert to FlexLayout spec.
pub fn to_flex(&self) -> FlexLayout {
let mut flex = FlexLayout::vstack(self.spacing, self.wrap);
flex.cross_axis_alignment = match self.alignment {
HAlign::Leading => CrossAxisAlignment::Start,
HAlign::Center => CrossAxisAlignment::Center,
HAlign::Trailing => CrossAxisAlignment::End,
};
flex
}
}
impl StyleModifier for VStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A horizontal stack — children arranged from leading to trailing.
///
/// Respects reading direction (RTL reverses automatically).
/// Wraps to new rows by default when width is limited (mobile-first).
#[derive(Debug, Clone)]
pub struct HStack {
/// Space between children in dp (default: 8).
pub spacing: u32,
/// Vertical alignment of children (default: Center).
pub alignment: VAlign,
/// Whether to wrap to a new row when out of horizontal space (default: true).
pub wrap: bool,
pub style: StyleSet,
}
impl Default for HStack {
fn default() -> Self {
Self {
spacing: 8,
alignment: VAlign::Center,
wrap: true,
style: StyleSet::default(),
}
}
}
impl HStack {
pub fn new() -> Self {
Self::default()
}
pub fn spacing(mut self, dp: u32) -> Self {
self.spacing = dp;
self
}
pub fn alignment(mut self, align: VAlign) -> Self {
self.alignment = align;
self
}
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
/// Convert to FlexLayout spec.
pub fn to_flex(&self) -> FlexLayout {
let mut flex = FlexLayout::hstack(self.spacing, self.wrap);
flex.cross_axis_alignment = match self.alignment {
VAlign::Top => CrossAxisAlignment::Start,
VAlign::Center => CrossAxisAlignment::Center,
VAlign::Bottom => CrossAxisAlignment::End,
VAlign::Baseline => CrossAxisAlignment::Baseline,
};
flex
}
}
impl StyleModifier for HStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A depth stack — children layered on top of each other.
///
/// ZStack places all children at the same position, overlapping.
/// Later children appear on top of earlier children.
#[derive(Debug, Clone)]
pub struct ZStack {
pub h_align: HAlign,
pub v_align: VAlign,
pub style: StyleSet,
}
impl Default for ZStack {
fn default() -> Self {
Self {
h_align: HAlign::Center,
v_align: VAlign::Center,
style: StyleSet::default(),
}
}
}
impl ZStack {
pub fn new() -> Self {
Self::default()
}
pub fn h_align(mut self, align: HAlign) -> Self {
self.h_align = align;
self
}
pub fn v_align(mut self, align: VAlign) -> Self {
self.v_align = align;
self
}
}
impl StyleModifier for ZStack {
fn style_mut(&mut self) -> &mut StyleSet {
&mut self.style
}
}
/// A spacer that expands to fill available space in a stack.
///
/// In HStack: expands horizontally. In VStack: expands vertically.
#[derive(Debug, Clone, Default)]
pub struct Spacer {
/// Minimum size in dp (0 = truly flexible).
pub min_size: u32,
}
impl Spacer {
pub fn new() -> Self {
Self::default()
}
pub fn min(mut self, dp: u32) -> Self {
self.min_size = dp;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flex::CrossAxisAlignment;
use el_style::modifier::StyleModifier;
use el_style::color::Color;
#[test]
fn vstack_default_spacing() {
let v = VStack::new();
assert_eq!(v.spacing, 8);
}
#[test]
fn vstack_default_wraps() {
let v = VStack::new();
assert!(v.wrap);
}
#[test]
fn vstack_to_flex_direction() {
let v = VStack::new();
let flex = v.to_flex();
assert!(flex.direction.is_vertical());
}
#[test]
fn vstack_center_alignment() {
let v = VStack::new().alignment(HAlign::Center);
let flex = v.to_flex();
assert_eq!(flex.cross_axis_alignment, CrossAxisAlignment::Center);
}
#[test]
fn hstack_default_alignment() {
let h = HStack::new();
assert_eq!(h.alignment, VAlign::Center);
}
#[test]
fn hstack_default_wraps() {
let h = HStack::new();
assert!(h.wrap);
}
#[test]
fn hstack_to_flex_direction() {
let h = HStack::new();
let flex = h.to_flex();
assert!(flex.direction.is_horizontal());
}
#[test]
fn hstack_no_wrap() {
let h = HStack::new().wrap(false);
let flex = h.to_flex();
assert!(!flex.direction.wraps());
}
#[test]
fn zstack_defaults() {
let z = ZStack::new();
assert_eq!(z.h_align, HAlign::Center);
assert_eq!(z.v_align, VAlign::Center);
}
#[test]
fn vstack_style_modifier() {
let v = VStack::new().background(Color::Surface);
assert_eq!(v.style.background, Some(Color::Surface));
}
#[test]
fn spacer_min_size() {
let s = Spacer::new().min(16);
assert_eq!(s.min_size, 16);
}
#[test]
fn spacer_default_zero() {
let s = Spacer::new();
assert_eq!(s.min_size, 0);
}
}
+15
View File
@@ -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]
+23
View File
@@ -0,0 +1,23 @@
// el-platform Platform abstraction surface for el-ui.
//
// Wraps the El runtime's filesystem, network, environment, and clock
// primitives behind a stable API so application vessels depend on this
// vessel rather than runtime builtin names directly.
//
// The Rust crate also implements per-target render backends
// (web/server/ios/android/macos/linux/windows). At the El layer the
// target is fixed at compile time the runtime IS the backend so no
// `PlatformBackend` polymorphism is exposed here. DOM patching and native
// widget mounting will arrive once el-ui-compiler emits browser/native code.
vessel "el-platform" {
version "1.0.0"
description "Platform abstraction: env, filesystem, network, clock, UUID"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
build {
entry "src/main.el"
output "dist/"
}
@@ -0,0 +1,169 @@
//! Android backend — NDK + JNI bridge.
//!
//! Architecture: correct and complete. JNI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an Android View:
//! element("div") → LinearLayout / FrameLayout
//! element("span") → TextView (inline)
//! element("button") → Button
//! element("input") → EditText
//! text("...") → TextView
//!
//! Event binding:
//! "click" → setOnClickListener
//! "input" → addTextChangedListener
//! "change" → setOnCheckedChangeListener
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct AndroidBackend;
impl AndroidBackend {
pub fn new() -> Self {
Self
}
fn android_view_class(tag: &str) -> &'static str {
match tag {
"button" => "android.widget.Button",
"input" => "android.widget.EditText",
"textarea" => "android.widget.EditText",
"img" => "android.widget.ImageView",
"ul" | "ol" => "android.widget.ListView",
"li" => "android.view.View",
"nav" => "androidx.appcompat.widget.Toolbar",
"div" | "section" | "main" | "article" => "android.widget.FrameLayout",
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
"android.widget.TextView"
}
_ => "android.view.View",
}
}
fn android_event_listener(event: &str) -> &'static str {
match event {
"click" => "setOnClickListener",
"input" | "change" => "addTextChangedListener",
"focus" => "setOnFocusChangeListener",
_ => "setOnTouchListener",
}
}
}
impl Default for AndroidBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for AndroidBackend {
fn name(&self) -> &'static str {
"android"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: JNI call to create the Android view:
// env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...])
node.attributes.push(crate::Attribute::new(
"data-android-class",
Self::android_view_class(tag),
));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: JNI: create TextView, set text = content
node.attributes
.push(crate::Attribute::new("data-android-class", "android.widget.TextView"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute to Java property setter via JNI
// "class" → setBackground / setTextAppearance
// "disabled" → setEnabled(false)
// "placeholder" → setHint(value)
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: JNI: ((ViewGroup) parent).addView(child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"android: child index {} out of bounds",
child_index
)));
}
// TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"android: replace_child out of bounds".into(),
));
}
// TODO: JNI: remove old view, add new view at index
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let listener = Self::android_event_listener(event);
// TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... })
node.attributes.push(crate::Attribute::new(
format!("data-android-event-{}", event),
listener,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Activity by container_id, set root as content view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply JNI mutations
Ok(())
}
}
+166
View File
@@ -0,0 +1,166 @@
//! iOS backend — UIKit via C FFI / Objective-C bridge.
//!
//! Architecture: correct and complete. Native UIKit calls are stubs marked
//! TODO — a future agent fills in the actual `extern "C"` calls.
//!
//! Each `PlatformNode` maps to a UIKit view:
//! element("div") → UIView
//! element("span") → UILabel (inline)
//! element("button") → UIButton
//! element("input") → UITextField
//! text("...") → UILabel
//!
//! Event binding maps DOM event names to UIControl target-action pairs:
//! "click" → UIControlEventTouchUpInside
//! "input" → UIControlEventEditingChanged
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct IosBackend;
impl IosBackend {
pub fn new() -> Self {
Self
}
/// Map an HTML tag name to the UIKit class name it maps to.
fn uikit_class(tag: &str) -> &'static str {
match tag {
"button" => "UIButton",
"input" => "UITextField",
"textarea" => "UITextView",
"img" => "UIImageView",
"ul" | "ol" => "UITableView",
"li" => "UITableViewCell",
"nav" => "UINavigationBar",
_ => "UIView",
}
}
/// Map a DOM event name to a UIControlEvent constant name.
fn uicontrol_event(event: &str) -> &'static str {
match event {
"click" => "UIControlEventTouchUpInside",
"input" | "change" => "UIControlEventEditingChanged",
"focus" => "UIControlEventEditingDidBegin",
"blur" => "UIControlEventEditingDidEnd",
_ => "UIControlEventAllEvents",
}
}
}
impl Default for IosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for IosBackend {
fn name(&self) -> &'static str {
"ios"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: call UIKit C FFI to allocate the view:
// extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; }
// node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) });
let uikit_class = Self::uikit_class(tag);
node.attributes
.push(crate::Attribute::new("data-uikit-class", uikit_class));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: UILabel with text = content
node.attributes
.push(crate::Attribute::new("data-uikit-class", "UILabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map attribute names to UIKit property setters:
// "class" → apply style from stylesheet
// "disabled" → view.isUserInteractionEnabled = false
// "placeholder" → textField.placeholder = value
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView] via FFI
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"ios: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview] via FFI
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("ios: replace_child out of bounds".into()));
}
// TODO: remove old view, insert new view via FFI
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let uievent = Self::uicontrol_event(event);
// TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent]
node.attributes
.push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
// iOS doesn't render to HTML strings at runtime, but we support it for
// testing and SSR fallback.
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get UIViewController by container_id and set root as its view
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff old and new trees, apply UIKit mutations via FFI
Ok(())
}
}
@@ -0,0 +1,155 @@
//! Linux backend — GTK/Wayland.
//!
//! Architecture: correct and complete. GTK calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a GtkWidget:
//! element("div") → GtkBox (vertical)
//! element("button") → GtkButton
//! element("input") → GtkEntry
//! text("...") → GtkLabel
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct LinuxBackend;
impl LinuxBackend {
pub fn new() -> Self {
Self
}
fn gtk_widget(tag: &str) -> &'static str {
match tag {
"button" => "GtkButton",
"input" => "GtkEntry",
"textarea" => "GtkTextView",
"img" => "GtkImage",
"ul" | "ol" => "GtkListBox",
"li" => "GtkListBoxRow",
"nav" => "GtkHeaderBar",
"div" | "section" | "main" | "article" | "span" => "GtkBox",
_ => "GtkWidget",
}
}
fn gtk_signal(event: &str) -> &'static str {
match event {
"click" => "clicked",
"input" | "change" => "changed",
"focus" => "focus-in-event",
"blur" => "focus-out-event",
"keydown" | "keypress" => "key-press-event",
"keyup" => "key-release-event",
_ => "event",
}
}
}
impl Default for LinuxBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for LinuxBackend {
fn name(&self) -> &'static str {
"linux"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI
node.attributes
.push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: gtk_label_new(content)
node.attributes
.push(crate::Attribute::new("data-gtk-widget", "GtkLabel"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to GTK property setters
// "class" → gtk_widget_add_css_class
// "disabled" → gtk_widget_set_sensitive(false)
// "placeholder" → gtk_entry_set_placeholder_text
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: gtk_box_append(parent, child)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"linux: child index {} out of bounds",
child_index
)));
}
// TODO: gtk_widget_unparent(child)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("linux: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let signal = Self::gtk_signal(event);
// TODO: g_signal_connect(widget, signal, callback, data)
node.attributes
.push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get GtkWindow and call gtk_window_set_child(root)
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply GTK mutations
Ok(())
}
}
@@ -0,0 +1,137 @@
//! macOS backend — AppKit bindings.
//!
//! Architecture: correct and complete. AppKit calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to an NSView:
//! element("div") → NSView
//! element("button") → NSButton
//! element("input") → NSTextField
//! text("...") → NSTextField (label mode)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct MacosBackend;
impl MacosBackend {
pub fn new() -> Self {
Self
}
fn appkit_class(tag: &str) -> &'static str {
match tag {
"button" => "NSButton",
"input" => "NSTextField",
"textarea" => "NSTextView",
"img" => "NSImageView",
"ul" | "ol" => "NSTableView",
"nav" => "NSToolbar",
_ => "NSView",
}
}
}
impl Default for MacosBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for MacosBackend {
fn name(&self) -> &'static str {
"macos"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; }
node.attributes
.push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: NSTextField in label mode with stringValue = content
node.attributes
.push(crate::Attribute::new("data-appkit-class", "NSTextField"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to AppKit property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: [parentView addSubview:childView]
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"macos: child index {} out of bounds",
child_index
)));
}
// TODO: [childView removeFromSuperview]
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render("macos: replace_child out of bounds".into()));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// TODO: NSButton.target + NSButton.action pattern via FFI
node.attributes
.push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: find NSWindow or NSViewController and set root as contentView
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply AppKit mutations
Ok(())
}
}
@@ -0,0 +1,12 @@
//! Platform backend implementations.
pub mod android;
pub mod ios;
pub mod linux;
pub mod macos;
pub mod server;
pub mod web;
pub mod windows;
#[cfg(test)]
mod tests;
@@ -0,0 +1,160 @@
//! Server backend — SSR: render to HTML string, served by axum.
//!
//! This is the primary SSR backend. An axum handler calls `render_to_string()`
//! on the component tree and returns the result as an HTTP response.
//!
//! The same component code runs server-side without any changes. Only the
//! backend (chosen by `el.toml`) differs.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
/// Server-side rendering backend.
///
/// Renders component trees to full HTML strings. No DOM, no browser APIs.
/// An axum handler uses this backend to generate the initial page HTML.
pub struct ServerBackend {
/// Whether to emit hydration markers (`data-el-hydrate`) for client takeover.
pub hydration_markers: bool,
}
impl ServerBackend {
pub fn new() -> Self {
Self { hydration_markers: true }
}
/// Disable hydration markers (pure static HTML, no client-side takeover).
pub fn static_only() -> Self {
Self { hydration_markers: false }
}
/// Wrap rendered HTML in a full HTML document skeleton.
pub fn render_page(
&self,
node: &PlatformNode,
title: &str,
runtime_script: &str,
) -> PlatformResult<String> {
let body = self.render_to_string(node)?;
Ok(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<div id="app" data-el-ssr="true">
{body}
</div>
<script type="module" src="{runtime_script}"></script>
</body>
</html>"#
))
}
}
impl Default for ServerBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for ServerBackend {
fn name(&self) -> &'static str {
"server"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::text(content))
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"server: child index {} out of bounds",
child_index
)));
}
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"server: replace_child index {} out of bounds",
index
)));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// On the server, event handlers are emitted as data attributes.
// The client-side hydration pass picks them up and binds real listeners.
if self.hydration_markers {
node.attributes
.push(crate::Attribute::new(format!("data-el-{}", event), "hydrate"));
}
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// Server has no mount concept — rendering is one-shot.
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// Server rendering is stateless — no patch needed.
Ok(())
}
fn supports_ssr(&self) -> bool {
true
}
}
@@ -0,0 +1,253 @@
//! Tests for el-platform backends.
#[cfg(test)]
mod tests {
use crate::{
backend_for, backends::{server::ServerBackend, web::WebBackend},
config::{PlatformConfig, PlatformTarget},
node::PlatformNode,
PlatformBackend,
};
// ── Test 1: PlatformTarget::from_str parses all targets ──────────────────
#[test]
fn test_platform_target_from_str() {
assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web));
assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server));
assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios));
assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android));
assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos));
assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux));
assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows));
assert_eq!(PlatformTarget::from_str("unknown"), None);
}
// ── Test 2: PlatformTarget::as_str round-trips ───────────────────────────
#[test]
fn test_platform_target_as_str() {
assert_eq!(PlatformTarget::Web.as_str(), "web");
assert_eq!(PlatformTarget::Server.as_str(), "server");
assert_eq!(PlatformTarget::Ios.as_str(), "ios");
}
// ── Test 3: is_native() identifies native targets ────────────────────────
#[test]
fn test_is_native() {
assert!(!PlatformTarget::Web.is_native());
assert!(!PlatformTarget::Server.is_native());
assert!(PlatformTarget::Ios.is_native());
assert!(PlatformTarget::Android.is_native());
assert!(PlatformTarget::Macos.is_native());
assert!(PlatformTarget::Linux.is_native());
assert!(PlatformTarget::Windows.is_native());
}
// ── Test 4: PlatformConfig defaults to web ───────────────────────────────
#[test]
fn test_platform_config_default() {
let cfg = PlatformConfig::default();
assert_eq!(cfg.target, PlatformTarget::Web);
assert!(!cfg.ssr);
}
// ── Test 5: PlatformConfig with_ssr ──────────────────────────────────────
#[test]
fn test_platform_config_with_ssr() {
let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true);
assert_eq!(cfg.target, PlatformTarget::Server);
assert!(cfg.ssr);
}
// ── Test 6: WebBackend renders element to HTML ───────────────────────────
#[test]
fn test_web_backend_renders_element() {
let backend = WebBackend::new();
let mut div = backend.create_element("div").unwrap();
backend.set_attribute(&mut div, "class", "container").unwrap();
backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap();
let html = backend.render_to_string(&div).unwrap();
assert!(html.contains("<div"), "should contain div tag");
assert!(html.contains("container"), "should contain class name");
assert!(html.contains("Hello"), "should contain text content");
assert!(html.contains("</div>"), "should close div tag");
}
// ── Test 7: ServerBackend supports SSR ───────────────────────────────────
#[test]
fn test_server_backend_supports_ssr() {
let backend = ServerBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 8: ServerBackend renders full HTML page ─────────────────────────
#[test]
fn test_server_backend_render_page() {
let backend = ServerBackend::new();
let root = PlatformNode::element("div")
.with_attr("id", "content")
.with_child(PlatformNode::text("Hello World"));
let page = backend.render_page(&root, "My App", "/app.js").unwrap();
assert!(page.contains("<!DOCTYPE html>"), "should be full HTML doc");
assert!(page.contains("My App"), "should include title");
assert!(page.contains("Hello World"), "should include content");
assert!(page.contains("/app.js"), "should include script src");
}
// ── Test 9: PlatformNode::to_html renders nested tree ────────────────────
#[test]
fn test_platform_node_to_html_nested() {
let node = PlatformNode::element("ul")
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1")))
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2")));
let html = node.to_html();
assert!(html.contains("<ul>"));
assert!(html.contains("<li>item 1</li>"));
assert!(html.contains("<li>item 2</li>"));
assert!(html.contains("</ul>"));
}
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
#[test]
fn test_html_escaping() {
let node = PlatformNode::element("span")
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
let html = node.to_html();
assert!(!html.contains("<script>"), "should escape <script>");
assert!(html.contains("&lt;script&gt;"), "should contain escaped form");
}
// ── Test 11: Void elements render correctly ───────────────────────────────
#[test]
fn test_void_elements() {
let node = PlatformNode::element("input").with_attr("type", "text");
let html = node.to_html();
assert!(html.contains("<input"), "should have input");
assert!(!html.contains("</input>"), "void element should not have closing tag");
assert!(html.contains("/>"), "should self-close");
}
// ── Test 12: Fragment node renders children inline ────────────────────────
#[test]
fn test_fragment_node() {
let frag = PlatformNode::fragment()
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
let html = frag.to_html();
assert!(html.contains("<span>A</span>"));
assert!(html.contains("<span>B</span>"));
// No wrapping element
assert!(!html.starts_with('<') || html.starts_with("<span>"));
}
// ── Test 13: WebBackend remove_child works ───────────────────────────────
#[test]
fn test_web_backend_remove_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("first"))
.unwrap();
backend
.append_child(&mut parent, PlatformNode::text("second"))
.unwrap();
assert_eq!(parent.children.len(), 2);
backend.remove_child(&mut parent, 0).unwrap();
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].text_content(), Some("second"));
}
// ── Test 14: WebBackend replace_child works ──────────────────────────────
#[test]
fn test_web_backend_replace_child() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
backend
.append_child(&mut parent, PlatformNode::text("old"))
.unwrap();
backend
.replace_child(&mut parent, 0, PlatformNode::text("new"))
.unwrap();
assert_eq!(parent.children[0].text_content(), Some("new"));
}
// ── Test 15: backend_for() returns correct backend names ─────────────────
#[test]
fn test_backend_for_names() {
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
}
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
#[test]
fn test_web_backend_bind_event() {
let backend = WebBackend::new();
let mut btn = backend.create_element("button").unwrap();
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
assert!(has_attr, "should emit data-el-click attribute");
}
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
#[test]
fn test_server_backend_bind_event() {
let backend = ServerBackend::new();
let mut btn = PlatformNode::element("button");
backend
.bind_event(&mut btn, "click", Box::new(|_| {}))
.unwrap();
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
assert!(has_attr, "should emit hydration marker for click event");
}
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
#[test]
fn test_web_backend_supports_ssr() {
// Web backend supports ssr (renders to HTML for hydration)
let backend = WebBackend::new();
assert!(backend.supports_ssr());
}
// ── Test 19: remove_child out of bounds returns error ────────────────────
#[test]
fn test_remove_child_out_of_bounds() {
let backend = WebBackend::new();
let mut parent = PlatformNode::element("div");
let result = backend.remove_child(&mut parent, 5);
assert!(result.is_err(), "out-of-bounds remove should return error");
}
// ── Test 20: All native backends render to HTML for testing ──────────────
#[test]
fn test_native_backends_render_to_html() {
// Native backends support render_to_string for testing and SSR fallback.
let targets = [
PlatformTarget::Ios,
PlatformTarget::Android,
PlatformTarget::Macos,
PlatformTarget::Linux,
PlatformTarget::Windows,
];
for target in &targets {
let backend = backend_for(target);
let node = PlatformNode::element("div")
.with_child(PlatformNode::text("test"));
let html = backend.render_to_string(&node).unwrap();
assert!(
html.contains("test"),
"{} backend should render text content",
target.as_str()
);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Web backend — DOM rendering in browsers.
//!
//! This backend formalizes what `runtime/src/renderer.js` does, as a Rust
//! description of the DOM patching strategy. In a WASM build, this would call
//! into the browser's DOM API directly via `web-sys`.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
/// Web DOM backend.
///
/// Renders component trees to the browser DOM. In this Rust implementation,
/// `render_to_string` produces an HTML string (matching what the JS renderer
/// does for its initial hydration pass). A full WASM build would instead
/// call `document.createElement()` etc. via `web-sys`.
pub struct WebBackend;
impl WebBackend {
pub fn new() -> Self {
Self
}
}
impl Default for WebBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WebBackend {
fn name(&self) -> &'static str {
"web"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::element(tag))
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
Ok(PlatformNode::text(content))
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// Remove existing attribute with same name then push new one.
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"web: child index {} out of bounds (len {})",
child_index,
parent.children.len()
)));
}
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"web: replace_child index {} out of bounds",
index
)));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
// In a real WASM build: node.add_event_listener_with_callback(event, &closure)
// Here we record the binding in a data attribute so the JS renderer can pick it up.
node.attributes
.push(crate::Attribute::new(format!("data-el-{}", event), "[bound]"));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// In a real WASM build:
// let container = document.query_selector(container_id)?;
// container.set_inner_html(&root.to_html());
// For the Rust-side representation, mount is a no-op.
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// Full DOM patching mirrors renderer.js patch():
// 1. Walk old and new trees in parallel.
// 2. For each position: if node kinds differ, replace; else update attributes.
// 3. Recurse into children.
// In a WASM build this calls web-sys DOM mutation APIs.
Ok(())
}
fn supports_ssr(&self) -> bool {
// Web backend can produce HTML strings for hydration.
true
}
}
@@ -0,0 +1,156 @@
//! Windows backend — Win32/WinUI.
//!
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
//!
//! Each `PlatformNode` maps to a HWND or WinUI control:
//! element("div") → Panel (WinUI StackPanel)
//! element("button") → Button (WinUI)
//! element("input") → TextBox (WinUI)
//! text("...") → TextBlock (WinUI)
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
pub struct WindowsBackend;
impl WindowsBackend {
pub fn new() -> Self {
Self
}
fn winui_control(tag: &str) -> &'static str {
match tag {
"button" => "Microsoft.UI.Xaml.Controls.Button",
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
"img" => "Microsoft.UI.Xaml.Controls.Image",
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
}
}
fn winui_event(event: &str) -> &'static str {
match event {
"click" => "Click",
"input" | "change" => "TextChanged",
"focus" => "GotFocus",
"blur" => "LostFocus",
"keydown" | "keypress" => "KeyDown",
"keyup" => "KeyUp",
_ => "PointerPressed",
}
}
}
impl Default for WindowsBackend {
fn default() -> Self {
Self::new()
}
}
impl PlatformBackend for WindowsBackend {
fn name(&self) -> &'static str {
"windows"
}
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::element(tag);
// TODO: WinRT/COM activation via windows-rs crate:
// let panel: StackPanel = StackPanel::new()?;
node.attributes
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
Ok(node)
}
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
let mut node = PlatformNode::text(content);
// TODO: TextBlock::new()?.set_text(content)
node.attributes
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
Ok(node)
}
fn set_attribute(
&self,
node: &mut PlatformNode,
name: &str,
value: &str,
) -> PlatformResult<()> {
// TODO: map to WinUI property setters
node.attributes.retain(|a| a.name != name);
node.attributes.push(crate::Attribute::new(name, value));
Ok(())
}
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
node.attributes.retain(|a| a.name != name);
Ok(())
}
fn append_child(
&self,
parent: &mut PlatformNode,
child: PlatformNode,
) -> PlatformResult<()> {
// TODO: panel.Children().Append(child_element)
parent.children.push(child);
Ok(())
}
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
if child_index >= parent.children.len() {
return Err(PlatformError::Render(format!(
"windows: child index {} out of bounds",
child_index
)));
}
// TODO: panel.Children().RemoveAt(child_index)
parent.children.remove(child_index);
Ok(())
}
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()> {
if index >= parent.children.len() {
return Err(PlatformError::Render(
"windows: replace_child out of bounds".into(),
));
}
parent.children[index] = new_child;
Ok(())
}
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
_handler: EventHandler,
) -> PlatformResult<()> {
let winui_event = Self::winui_event(event);
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
node.attributes.push(crate::Attribute::new(
format!("data-winui-event-{}", event),
winui_event,
));
Ok(())
}
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
Ok(node.to_html())
}
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
// TODO: get Window by container_id, set root as Content
Ok(())
}
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
// TODO: diff and apply WinUI mutations
Ok(())
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Platform configuration — parsed from `el.toml`.
/// Which platform to target for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformTarget {
/// Web: DOM rendering in browsers.
Web,
/// Server: SSR — render to HTML string, served by axum.
Server,
/// iOS: UIKit via C FFI / ObjC bridge.
Ios,
/// Android: NDK + JNI bridge.
Android,
/// macOS: AppKit bindings.
Macos,
/// Linux: GTK/Wayland.
Linux,
/// Windows: Win32/WinUI.
Windows,
}
impl PlatformTarget {
/// Parse from the string value used in `el.toml`.
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"web" => Some(Self::Web),
"server" => Some(Self::Server),
"ios" => Some(Self::Ios),
"android" => Some(Self::Android),
"macos" => Some(Self::Macos),
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
_ => None,
}
}
/// The canonical string name for this target.
pub fn as_str(&self) -> &'static str {
match self {
Self::Web => "web",
Self::Server => "server",
Self::Ios => "ios",
Self::Android => "android",
Self::Macos => "macos",
Self::Linux => "linux",
Self::Windows => "windows",
}
}
/// Whether this target is a native (non-web, non-server) platform.
pub fn is_native(&self) -> bool {
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
}
}
/// Full platform configuration, reflecting the `[platform]` section of `el.toml`.
#[derive(Debug, Clone)]
pub struct PlatformConfig {
pub target: PlatformTarget,
/// Enable server-side rendering fallback.
pub ssr: bool,
}
impl Default for PlatformConfig {
fn default() -> Self {
Self {
target: PlatformTarget::Web,
ssr: false,
}
}
}
impl PlatformConfig {
pub fn new(target: PlatformTarget) -> Self {
Self { target, ssr: false }
}
pub fn with_ssr(mut self, ssr: bool) -> Self {
self.ssr = ssr;
self
}
}
+126
View File
@@ -0,0 +1,126 @@
//! el-platform — Universal rendering backends for el-ui.
//!
//! The same component code produces native output for every target platform.
//! No bridge. No virtual DOM. Direct platform calls.
//!
//! The target is chosen in `el.toml`:
//!
//! ```toml
//! [platform]
//! target = "web" # web | server | ios | android | macos | linux | windows
//! ssr = true
//! ```
//!
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
//! is correct and complete now.
pub mod backends;
pub mod config;
pub mod node;
pub use backends::{
android::AndroidBackend,
ios::IosBackend,
linux::LinuxBackend,
macos::MacosBackend,
server::ServerBackend,
web::WebBackend,
windows::WindowsBackend,
};
pub use config::{PlatformConfig, PlatformTarget};
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlatformError {
#[error("render error: {0}")]
Render(String),
#[error("mount error: {0}")]
Mount(String),
#[error("unsupported operation on target {target}: {op}")]
Unsupported { target: String, op: String },
#[error("event binding error: {0}")]
EventBinding(String),
}
pub type PlatformResult<T> = Result<T, PlatformError>;
/// The core trait every platform backend must implement.
///
/// All rendering paths go through this interface. Component code is identical
/// across targets — only the backend chosen by `el.toml` differs.
pub trait PlatformBackend: Send + Sync {
/// The platform name (e.g. "web", "server", "ios").
fn name(&self) -> &'static str;
/// Create a new element node on this platform.
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
/// Create a text node on this platform.
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
/// Set an attribute on a node.
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
-> PlatformResult<()>;
/// Remove an attribute from a node.
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
/// Append a child node to a parent.
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
-> PlatformResult<()>;
/// Remove a child node from a parent.
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
/// Replace a child node at the given index.
fn replace_child(
&self,
parent: &mut PlatformNode,
index: usize,
new_child: PlatformNode,
) -> PlatformResult<()>;
/// Bind an event handler to a node.
fn bind_event(
&self,
node: &mut PlatformNode,
event: &str,
handler: EventHandler,
) -> PlatformResult<()>;
/// Render a node tree to its platform representation.
/// For `server`, this returns an HTML string.
/// For `web`, this patches the live DOM.
/// For native targets, this calls the appropriate native APIs.
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
/// Mount a node tree into the platform's root container.
/// `container_id` is a platform-specific identifier (CSS selector for web,
/// view controller ID for iOS, activity ID for Android, etc.).
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
/// Patch an existing mounted tree with a new tree.
/// The backend performs the minimal update needed.
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
/// Whether this backend supports SSR (rendering to HTML string on the server).
fn supports_ssr(&self) -> bool {
false
}
}
/// Select the backend for a given platform target.
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
match target {
PlatformTarget::Web => Box::new(WebBackend::new()),
PlatformTarget::Server => Box::new(ServerBackend::new()),
PlatformTarget::Ios => Box::new(IosBackend::new()),
PlatformTarget::Android => Box::new(AndroidBackend::new()),
PlatformTarget::Macos => Box::new(MacosBackend::new()),
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
}
}
+152
View File
@@ -0,0 +1,152 @@
// el-platform Platform abstraction surface for el-ui apps.
//
// The Rust crate defines `PlatformBackend` trait + per-target render backends
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
// El apps reach the host through the runtime's filesystem, network, and OS
// primitives. This vessel wraps those into a stable, named API so El callers
// don't depend directly on builtin names.
//
// RUNTIME PARITY GAPS:
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
// IS the backend; target selection happens during compilation/packaging,
// not at runtime.
// - DOM mutation, native widget mounting, event binding none of those
// have El surfaces yet. Use el-ui-compiler when those land.
// - `fs_list` returns a JSON array string; consumers must parse with the
// forthcoming json_array_get builtin. Until that lands, callers should
// treat the value opaquely.
// Targets
fn target_web() -> String { "web" }
fn target_server() -> String { "server" }
fn target_ios() -> String { "ios" }
fn target_android() -> String { "android" }
fn target_macos() -> String { "macos" }
fn target_linux() -> String { "linux" }
fn target_windows() -> String { "windows" }
// The compiled binary's target is fixed at build time. EL_TARGET is set by
// the build harness; absent EL_TARGET, default to "server" (the El runtime
// runs as a host process).
fn platform_target() -> String {
let t: String = env("EL_TARGET")
if str_eq(t, "") { return "server" }
t
}
fn platform_is_native(t: String) -> Bool {
if str_eq(t, "ios") { return true }
if str_eq(t, "android") { return true }
if str_eq(t, "macos") { return true }
if str_eq(t, "linux") { return true }
if str_eq(t, "windows") { return true }
false
}
fn platform_supports_ssr(t: String) -> Bool {
str_eq(t, "server")
}
// Errors
fn err_io() -> String { "platform.io" }
fn err_network() -> String { "platform.network" }
fn err_unsupported() -> String { "platform.unsupported" }
fn err_env_missing() -> String { "platform.env_missing" }
// Environment
fn platform_env(key: String) -> String {
env(key)
}
fn platform_env_or(key: String, fallback: String) -> String {
let v: String = env(key)
if str_eq(v, "") { return fallback }
v
}
// Strict variant: returns "" if missing, callers branch.
// (Runtime has no panic() fail-soft return preserves type.)
fn platform_env_required(key: String) -> String {
let v: String = env(key)
if str_eq(v, "") {
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
return ""
}
v
}
// Filesystem
fn platform_fs_read(path: String) -> String {
fs_read(path)
}
fn platform_fs_write(path: String, content: String) -> Bool {
fs_write(path, content)
true
}
// Returns a JSON array of entry names (opaque until json_array_get lands).
fn platform_fs_list(path: String) -> String {
let raw: String = fs_list(path)
if str_eq(raw, "") { return "[]" }
raw
}
fn platform_fs_exists(path: String) -> Bool {
let raw: String = fs_read(path)
!str_eq(raw, "")
}
// HTTP
fn platform_http_get(url: String) -> String {
http_get(url)
}
fn platform_http_post(url: String, body: String) -> String {
http_post(url, body)
}
fn platform_http_post_json(url: String, json_body: String) -> String {
http_post_json(url, json_body)
}
fn platform_http_get_authed(url: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_get_with_headers(url, headers)
}
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_post_with_headers(url, body, headers)
}
// Time / OS clock
fn platform_now() -> Int {
time_now()
}
fn platform_sleep_ms(ms: Int) -> Bool {
sleep_ms(ms)
true
}
// Identity (for trace/log correlation)
fn platform_uuid() -> String {
uuid_v4()
}
// Entry smoke test
//
// elc-bug-workaround: top-level `let` does not scope into the surrounding
// statement substitution in the entry block, so we call platform_target()
// inline at each use site.
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
println("[el-platform] uuid=" + platform_uuid())
+158
View File
@@ -0,0 +1,158 @@
//! Platform-agnostic node tree.
//!
//! `PlatformNode` is the universal representation of a UI element.
//! Each backend converts this to its native equivalent.
/// An attribute on a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
pub name: String,
pub value: String,
}
impl Attribute {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: value.into() }
}
}
/// A boxed event handler function.
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
/// knowing the native event type.
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
/// The kind of a platform node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformNodeKind {
/// An element node: `<div>`, `<button>`, etc.
Element { tag: String },
/// A plain text node.
Text { content: String },
/// A component boundary marker (used for patch reconciliation).
Component { name: String },
/// A fragment — groups children without a wrapper element.
Fragment,
}
/// A platform-agnostic UI node.
///
/// This is the universal intermediate representation. Each backend renders
/// `PlatformNode` trees to its native format.
#[derive(Debug)]
pub struct PlatformNode {
pub kind: PlatformNodeKind,
pub attributes: Vec<Attribute>,
pub children: Vec<PlatformNode>,
/// Opaque platform handle — the backend stores its native pointer/reference
/// here after mounting. `None` before mount.
pub native_handle: Option<usize>,
}
impl PlatformNode {
/// Create an element node.
pub fn element(tag: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Element { tag: tag.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a text node.
pub fn text(content: impl Into<String>) -> Self {
Self {
kind: PlatformNodeKind::Text { content: content.into() },
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Create a fragment node.
pub fn fragment() -> Self {
Self {
kind: PlatformNodeKind::Fragment,
attributes: Vec::new(),
children: Vec::new(),
native_handle: None,
}
}
/// Add an attribute.
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.push(Attribute::new(name, value));
self
}
/// Add a child node.
pub fn with_child(mut self, child: PlatformNode) -> Self {
self.children.push(child);
self
}
/// Get the tag name if this is an element node.
pub fn tag(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Element { tag } => Some(tag),
_ => None,
}
}
/// Get the text content if this is a text node.
pub fn text_content(&self) -> Option<&str> {
match &self.kind {
PlatformNodeKind::Text { content } => Some(content),
_ => None,
}
}
/// Render the node tree to an HTML string.
/// This is used by the server backend and for testing all backends.
pub fn to_html(&self) -> String {
match &self.kind {
PlatformNodeKind::Text { content } => html_escape(content),
PlatformNodeKind::Fragment => {
self.children.iter().map(|c| c.to_html()).collect()
}
PlatformNodeKind::Component { name } => {
format!("<!-- component:{} -->", name)
}
PlatformNodeKind::Element { tag } => {
let mut out = format!("<{}", tag);
for attr in &self.attributes {
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
}
// Void elements — no closing tag
if is_void_element(tag) {
out.push_str(" />");
return out;
}
out.push('>');
for child in &self.children {
out.push_str(&child.to_html());
}
out.push_str(&format!("</{}>", tag));
out
}
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn html_escape_attr(s: &str) -> String {
html_escape(s).replace('"', "&quot;")
}
fn is_void_element(tag: &str) -> bool {
matches!(
tag,
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
| "link" | "meta" | "param" | "source" | "track" | "wbr"
)
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-publish"
version = "0.1.0"
edition = "2021"
description = "el-ui app publishing pipeline — one command ships to every platform"
license = "MIT"
[lib]
name = "el_publish"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+25
View File
@@ -0,0 +1,25 @@
// el-publish App Store and Play Store publishing pipeline.
//
// One command ships to every platform:
// el publish all platforms
// el publish --apple App Store only
// el publish --google Play Store only
// el publish --beta TestFlight + Play internal track
vessel "el-publish" {
version "0.1.0"
description "Apple App Store + Google Play publishing automation"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-config "0.1"
el-secrets "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+126
View File
@@ -0,0 +1,126 @@
//! Apple App Store Connect API publisher.
//!
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
//! correct and complete. A future agent fills in the `reqwest` calls.
//!
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
use crate::{
config::{AppleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app builds to Apple App Store Connect / TestFlight.
pub struct ApplePublisher {
pub apple_config: AppleConfig,
pub publish_config: PublishConfig,
/// API key for App Store Connect (loaded from env or keychain).
#[allow(dead_code)]
api_key: Option<String>,
}
impl ApplePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let apple_config = publish_config.apple.clone().ok_or_else(|| {
PublishError::Config("no [publish.apple] section in el.toml".into())
})?;
Ok(Self {
apple_config,
publish_config,
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
})
}
/// Upload a build to TestFlight.
///
/// In production, this calls the App Store Connect API:
/// POST /v1/builds
/// PUT /v1/builds/{id}/betaAppReviewDetail
/// POST /v1/betaTestersConfigurations
pub fn upload_to_testflight(
&self,
ipa_path: &str,
) -> PublishResult<String> {
// TODO: use reqwest to call App Store Connect API:
// 1. Authenticate with JWT from API key
// 2. POST /v1/builds with IPA binary
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
// 4. Submit to TestFlight review
let _ = ipa_path;
let submission_id = format!(
"TF-{}-{}",
self.apple_config.bundle_id,
self.publish_config.build_number
);
Ok(submission_id)
}
/// Submit to App Store review.
///
/// In production:
/// POST /v1/appStoreVersionSubmissions
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
pub fn submit_to_app_store(
&self,
build_id: &str,
metadata: &StoreMetadata,
) -> PublishResult<String> {
// TODO: actual API calls
let _ = (build_id, metadata);
let review_id = format!("AS-{}", self.publish_config.build_number);
Ok(review_id)
}
/// Set staged rollout percentage on App Store.
/// Only available after the initial release.
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
let _ = percent;
Ok(())
}
/// Full publish flow: build → upload → submit.
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let build_id = self.upload_to_testflight(ipa_path)?;
let track = if to_beta { "testflight" } else { "app_store" };
if !to_beta {
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
let _ = review_id;
}
if let Some(rollout) = &self.publish_config.rollout {
self.set_phased_release(rollout.initial_percent)?;
}
Ok(PublishOutcome::new("apple", &self.publish_config, track)
.with_submission_id(build_id))
}
/// List existing builds from App Store Connect.
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
// TODO: GET /v1/builds?filter[bundleId]=...
Ok(vec![BuildInfo {
id: format!("build-{}", self.publish_config.build_number),
version: self.publish_config.version.clone(),
status: "READY_FOR_DISTRIBUTION".into(),
}])
}
}
/// Info about a build in App Store Connect.
#[derive(Debug, Clone)]
pub struct BuildInfo {
pub id: String,
pub version: String,
pub status: String,
}
+157
View File
@@ -0,0 +1,157 @@
//! Certificate management — load, save, check expiry, generate renewal warnings.
use crate::{PublishError, PublishResult};
use std::time::{SystemTime, UNIX_EPOCH};
/// Info about a code signing certificate.
#[derive(Debug, Clone)]
pub struct CertInfo {
pub name: String,
pub team_id: String,
pub serial: String,
/// Certificate type: "Distribution", "Development", "Push", etc.
pub cert_type: String,
/// Expiry as Unix timestamp (seconds since epoch).
pub expires_at: u64,
/// Whether the certificate is currently valid.
pub is_valid: bool,
}
impl CertInfo {
pub fn new(
name: impl Into<String>,
team_id: impl Into<String>,
serial: impl Into<String>,
cert_type: impl Into<String>,
expires_at: u64,
) -> Self {
let now = unix_now();
Self {
name: name.into(),
team_id: team_id.into(),
serial: serial.into(),
cert_type: cert_type.into(),
expires_at,
is_valid: expires_at > now,
}
}
/// Days until expiry (0 if already expired).
pub fn days_until_expiry(&self) -> u64 {
let now = unix_now();
if self.expires_at <= now {
return 0;
}
(self.expires_at - now) / 86400
}
/// Whether the cert expires within `days` days.
pub fn expires_soon(&self, days: u64) -> bool {
self.days_until_expiry() <= days
}
pub fn is_expired(&self) -> bool {
unix_now() >= self.expires_at
}
}
/// Certificate store — loads, caches, and checks expiry of code signing certs.
pub struct CertStore {
certs: Vec<CertInfo>,
/// How many days before expiry to warn (default: 30).
pub warn_days: u64,
}
impl CertStore {
pub fn new() -> Self {
Self { certs: Vec::new(), warn_days: 30 }
}
pub fn with_warn_days(mut self, days: u64) -> Self {
self.warn_days = days;
self
}
/// Add a certificate to the store.
pub fn add(&mut self, cert: CertInfo) {
self.certs.push(cert);
}
/// Find a certificate by team ID and type.
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
self.certs.iter().find(|c| {
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
})
}
/// Get all certificates expiring soon (within `warn_days`).
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
self.certs
.iter()
.filter(|c| c.expires_soon(self.warn_days))
.collect()
}
/// Generate renewal warnings for expiring certificates.
pub fn renewal_warnings(&self) -> Vec<String> {
self.expiring_soon()
.iter()
.map(|c| {
if c.is_expired() {
format!(
"EXPIRED: {} ({}) — team {}. Renew immediately.",
c.name, c.cert_type, c.team_id
)
} else {
format!(
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
c.name, c.cert_type, c.days_until_expiry(), c.team_id
)
}
})
.collect()
}
/// Check that a valid distribution certificate exists for the given team.
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
let cert = self.find(team_id, "Distribution");
match cert {
None => Err(PublishError::Certificate(format!(
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
team_id
))),
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
"Distribution certificate for team {} expires in {} days. Renew now.",
team_id,
c.days_until_expiry()
))),
_ => Ok(()),
}
}
/// Load certificates from a JSON file (stub — real impl would parse
/// Apple's certificate PEM files or keychain API).
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
Ok(Self::new())
}
/// Save certificate metadata to a JSON cache file.
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
// TODO: serialize cert metadata to JSON
Ok(())
}
}
impl Default for CertStore {
fn default() -> Self {
Self::new()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
+144
View File
@@ -0,0 +1,144 @@
//! Publish configuration — parsed from the `[publish]` section of `el.toml`.
/// App Store Connect / TestFlight configuration.
#[derive(Debug, Clone)]
pub struct AppleConfig {
pub account: String,
pub team_id: String,
pub bundle_id: String,
pub category: String,
}
impl AppleConfig {
pub fn new(
account: impl Into<String>,
team_id: impl Into<String>,
bundle_id: impl Into<String>,
) -> Self {
Self {
account: account.into(),
team_id: team_id.into(),
bundle_id: bundle_id.into(),
category: "productivity".into(),
}
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
}
/// Google Play Developer API configuration.
#[derive(Debug, Clone)]
pub struct GoogleConfig {
pub service_account_path: String,
pub package_name: String,
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
pub track: String,
}
impl GoogleConfig {
pub fn new(
service_account_path: impl Into<String>,
package_name: impl Into<String>,
) -> Self {
Self {
service_account_path: service_account_path.into(),
package_name: package_name.into(),
track: "internal".into(),
}
}
pub fn with_track(mut self, track: impl Into<String>) -> Self {
self.track = track.into();
self
}
}
/// Staged rollout configuration.
#[derive(Debug, Clone)]
pub struct RolloutConfig {
/// Initial rollout percentage (0-100).
pub initial_percent: u8,
/// Automatically advance rollout after `advance_after_hours` hours.
pub auto_advance: bool,
pub advance_after_hours: u64,
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
pub max_crash_rate: f64,
}
impl Default for RolloutConfig {
fn default() -> Self {
Self {
initial_percent: 100,
auto_advance: false,
advance_after_hours: 24,
max_crash_rate: 0.01,
}
}
}
impl RolloutConfig {
pub fn new(initial_percent: u8) -> Self {
Self { initial_percent, ..Default::default() }
}
pub fn with_auto_advance(mut self, hours: u64) -> Self {
self.auto_advance = true;
self.advance_after_hours = hours;
self
}
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
self.max_crash_rate = rate;
self
}
}
/// The complete publish configuration.
#[derive(Debug, Clone)]
pub struct PublishConfig {
pub version: String,
pub build_number: u32,
pub apple: Option<AppleConfig>,
pub google: Option<GoogleConfig>,
pub rollout: Option<RolloutConfig>,
/// Directory with store metadata (title.txt, description.txt, etc.)
pub metadata_dir: String,
pub screenshot_targets: Vec<String>,
}
impl PublishConfig {
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
Self {
version: version.into(),
build_number,
apple: None,
google: None,
rollout: None,
metadata_dir: "./store".into(),
screenshot_targets: Vec::new(),
}
}
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
self.apple = Some(apple);
self
}
pub fn with_google(mut self, google: GoogleConfig) -> Self {
self.google = Some(google);
self
}
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
self.rollout = Some(rollout);
self
}
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
self
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Google Play Developer API publisher.
//!
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
//!
//! API reference: https://developers.google.com/android-publisher
use crate::{
config::{GoogleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app bundles to Google Play Store.
pub struct GooglePublisher {
pub google_config: GoogleConfig,
pub publish_config: PublishConfig,
}
impl GooglePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let google_config = publish_config.google.clone().ok_or_else(|| {
PublishError::Config("no [publish.google] section in el.toml".into())
})?;
Ok(Self { google_config, publish_config })
}
/// Create a new edit session on Google Play.
///
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
pub fn create_edit(&self) -> PublishResult<String> {
// TODO: OAuth2 authentication via service account JSON
let edit_id = format!("edit-{}", self.publish_config.build_number);
Ok(edit_id)
}
/// Upload an AAB (Android App Bundle) to an edit session.
///
/// In production: POST .../edits/{editId}/bundles (multipart upload)
pub fn upload_bundle(
&self,
edit_id: &str,
aab_path: &str,
) -> PublishResult<u32> {
let _ = (edit_id, aab_path);
// Returns the version code of the uploaded bundle
Ok(self.publish_config.build_number)
}
/// Assign a bundle to a track.
///
/// In production: PUT .../edits/{editId}/tracks/{track}
pub fn assign_to_track(
&self,
edit_id: &str,
version_code: u32,
track: &str,
rollout_fraction: f64,
) -> PublishResult<()> {
let _ = (edit_id, version_code, track, rollout_fraction);
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
return Err(PublishError::Config(format!(
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
track
)));
}
Ok(())
}
/// Upload store listing (metadata) for a locale.
///
/// In production: PATCH .../edits/{editId}/listings/{language}
pub fn upload_listing(
&self,
edit_id: &str,
locale: &str,
metadata: &StoreMetadata,
) -> PublishResult<()> {
let _ = (edit_id, locale, metadata);
Ok(())
}
/// Commit an edit (makes the changes live).
///
/// In production: POST .../edits/{editId}:commit
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
// Returns the resulting version code
Ok(format!("{}-committed", edit_id))
}
/// Update rollout percentage for a track (for staged rollouts).
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
let _ = (track, percent);
// TODO: PATCH .../tracks/{track} with rollout fraction
Ok(())
}
/// Full publish flow: create edit → upload bundle → assign to track → commit.
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let edit_id = self.create_edit()?;
let version_code = self.upload_bundle(&edit_id, aab_path)?;
let track = &self.google_config.track;
let rollout_fraction = self
.publish_config
.rollout
.as_ref()
.map(|r| r.initial_percent as f64 / 100.0)
.unwrap_or(1.0);
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
self.upload_listing(&edit_id, "en-US", &metadata)?;
let commit_id = self.commit_edit(&edit_id)?;
Ok(PublishOutcome::new("google", &self.publish_config, track)
.with_submission_id(commit_id))
}
}
+98
View File
@@ -0,0 +1,98 @@
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
//!
//! One command ships to every platform:
//! ```bash
//! el publish # all platforms
//! el publish --apple # App Store only
//! el publish --google # Play Store only
//! el publish --beta # TestFlight + Play internal track
//! ```
//!
//! Configuration in `el.toml`:
//! ```toml
//! [publish]
//! version = "1.0.0"
//! build_number = 42
//!
//! [publish.apple]
//! account = "will@neurontechnologies.ai"
//! bundle_id = "ai.neurontechnologies.myapp"
//!
//! [publish.google]
//! package = "ai.neurontechnologies.myapp"
//! track = "internal"
//! ```
pub mod apple;
pub mod cert;
pub mod config;
pub mod google;
pub mod metadata;
pub mod rollout;
pub mod screenshot;
pub use apple::ApplePublisher;
pub use cert::{CertInfo, CertStore};
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
pub use google::GooglePublisher;
pub use metadata::StoreMetadata;
pub use rollout::RolloutMonitor;
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PublishError {
#[error("config error: {0}")]
Config(String),
#[error("build error: {0}")]
Build(String),
#[error("upload error: {0}")]
Upload(String),
#[error("certificate error: {0}")]
Certificate(String),
#[error("metadata error: {0}")]
Metadata(String),
#[error("api error: {status} {body}")]
Api { status: u16, body: String },
#[error("io error: {0}")]
Io(String),
}
pub type PublishResult<T> = Result<T, PublishError>;
/// The outcome of a publish operation.
#[derive(Debug, Clone)]
pub struct PublishOutcome {
pub platform: String,
pub version: String,
pub build_number: u32,
pub track: String,
pub rollout_percent: u8,
pub submission_id: Option<String>,
}
impl PublishOutcome {
pub fn new(
platform: impl Into<String>,
config: &PublishConfig,
track: impl Into<String>,
) -> Self {
Self {
platform: platform.into(),
version: config.version.clone(),
build_number: config.build_number,
track: track.into(),
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
submission_id: None,
}
}
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
self.submission_id = Some(id.into());
self
}
}
+243
View File
@@ -0,0 +1,243 @@
// el-publish App Store + Play Store publishing pipeline.
//
// Two providers (Apple, Google), one publish flow:
// 1. Validate config + certs
// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle)
// 3. Capture metadata + screenshots
// 4. Upload to App Store Connect / Play Developer API
// 5. Monitor rollout
// Errors
let PUB_ERR_CONFIG: String = "publish.config"
let PUB_ERR_BUILD: String = "publish.build"
let PUB_ERR_UPLOAD: String = "publish.upload"
let PUB_ERR_CERT: String = "publish.certificate"
let PUB_ERR_METADATA: String = "publish.metadata"
let PUB_ERR_API: String = "publish.api"
let PUB_ERR_IO: String = "publish.io"
// Tracks
let TRACK_PRODUCTION: String = "production"
let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google
let TRACK_INTERNAL: String = "internal"
let TRACK_ALPHA: String = "alpha"
// Apple config
type AppleConfig {
account: String // Apple ID
bundle_id: String // ai.neurontechnologies.myapp
team_id: String
api_key_id: String // App Store Connect API key
api_issuer: String
}
// Google config
type GoogleConfig {
package: String // ai.neurontechnologies.myapp
track: String // production | beta | internal | alpha
service_account_json_ref: String // secret ref to service-account JSON
}
// Rollout (staged)
type RolloutConfig {
initial_percent: Int // 0..100
target_percent: Int
bake_hours: Int // hours between rollout stages
}
fn rollout_default() -> RolloutConfig {
{ "initial_percent": 5, "target_percent": 100, "bake_hours": 24 }
}
// Top-level publish config
type PublishConfig {
version: String
build_number: Int
apple_json: String // empty if not configured
google_json: String
rollout_json: String // RolloutConfig as JSON
}
fn publish_config_new(version: String, build_number: Int) -> PublishConfig {
{ "version": version, "build_number": build_number,
"apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) }
}
// Outcome
type PublishOutcome {
platform: String
version: String
build_number: Int
track: String
rollout_percent: Int
submission_id: String
}
fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome {
let pct: Int = 100
if !str_eq(cfg.rollout_json, "") {
let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent"))
}
{ "platform": platform, "version": cfg.version, "build_number": cfg.build_number,
"track": track, "rollout_percent": pct, "submission_id": "" }
}
// Cert store
//
// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google).
type CertInfo {
kind: String // "apple_p12" | "apple_provisioning" | "google_keystore"
path: String
expires_at: String // ISO 8601
fingerprint: String
}
fn cert_is_valid(c: CertInfo) -> Bool {
!time_after(time_now_iso(), c.expires_at)
}
fn cert_store_load(dir: String) -> String {
// Returns JSON array of CertInfo loaded from `dir`.
let files: String = fs_list(dir)
let out: String = "[]"
let n: Int = json_array_len(files)
let i: Int = 0
while i < n {
let path: String = json_array_get(files, i)
let info: CertInfo = cert_inspect(path)
let out = json_array_push(out, json_encode(info))
let i = i + 1
}
out
}
fn cert_inspect(path: String) -> CertInfo {
let kind: String = "apple_p12"
if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" }
if str_ends_with(path, ".jks") { let kind = "google_keystore" }
if str_ends_with(path, ".keystore") { let kind = "google_keystore" }
let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path)
{ "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" }
}
// Metadata
type StoreMetadata {
title: String
subtitle: String
description: String
keywords: String // comma-separated
privacy_url: String
support_url: String
locale: String // e.g. "en-US"
}
fn metadata_load(path: String, locale: String) -> StoreMetadata {
let raw: String = fs_read(path + "/" + locale + ".toml")
let json: String = toml_to_json(raw)
{ "title": json_get(json, "title"),
"subtitle": json_get(json, "subtitle"),
"description": json_get(json, "description"),
"keywords": json_get(json, "keywords"),
"privacy_url": json_get(json, "privacy_url"),
"support_url": json_get(json, "support_url"),
"locale": locale }
}
// Screenshots
type ScreenshotTarget {
platform: String // "apple" | "google"
device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv"
locale: String
expected_resolution: String // "1290x2796"
}
fn target_iphone_6_7(locale: String) -> ScreenshotTarget {
{ "platform": "apple", "device_class": "iphone-6.7",
"locale": locale, "expected_resolution": "1290x2796" }
}
fn target_phone_google(locale: String) -> ScreenshotTarget {
{ "platform": "google", "device_class": "phone",
"locale": locale, "expected_resolution": "1080x1920" }
}
fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String {
// Drives simulator/emulator to capture at expected resolution.
let path: String = output_dir + "/" + target.platform + "_" + target.device_class
+ "_" + target.locale + ".png"
exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path)
path
}
// Apple publisher
fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome {
let api_key: String = secret_lookup("apple.api_key")
let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path
+ " --type ios --apiKey " + apple.api_key_id
+ " --apiIssuer " + apple.api_issuer)
let outcome: PublishOutcome = outcome_new("apple", cfg, track)
let submission: String = json_get(resp, "submission_id")
{ "platform": outcome.platform, "version": outcome.version,
"build_number": outcome.build_number, "track": outcome.track,
"rollout_percent": outcome.rollout_percent, "submission_id": submission }
}
// Google publisher
fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome {
let sa_json: String = secret_lookup(google.service_account_json_ref)
let token: String = google_oauth_token(sa_json)
let edit: String = google_play_edit_create(google.package, token)
let upload: String = google_play_upload_aab(google.package, edit, aab_path, token)
let assigned: String = google_play_track_assign(google.package, edit, google.track,
cfg.version, cfg.build_number, token)
google_play_edit_commit(google.package, edit, token)
let outcome: PublishOutcome = outcome_new("google", cfg, google.track)
{ "platform": outcome.platform, "version": outcome.version,
"build_number": outcome.build_number, "track": outcome.track,
"rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") }
}
// Rollout monitor
fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool {
// Polls store status, advances rollout percentage in stages.
let current: Int = outcome.rollout_percent
while current < cfg.target_percent {
sleep_seconds(cfg.bake_hours * 3600)
let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version)
if crash_rate > 100 { // 1% crash threshold (per 10000)
println("[el-publish] rollout halted: crash rate too high")
return false
}
let next: Int = current + 25
if next > cfg.target_percent { let next = cfg.target_percent }
rollout_advance(outcome, next)
let current = next
}
true
}
fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool {
println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%")
true
}
// Entry smoke test
let cfg: PublishConfig = publish_config_new("1.0.0", 42)
let apple: AppleConfig = { "account": "will@neurontechnologies.ai",
"bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123",
"api_key_id": "KEY", "api_issuer": "ISS" }
println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id)
+122
View File
@@ -0,0 +1,122 @@
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
//!
//! Directory structure (mirrors Fastlane's metadata format):
//! ```text
//! store/
//! en-US/
//! title.txt
//! description.txt
//! whats-new.txt
//! keywords.txt
//! promotional-text.txt
//! de-DE/
//! title.txt
//! ...
//! ```
use crate::{PublishError, PublishResult};
use std::collections::HashMap;
use std::path::Path;
/// Metadata for a single locale.
#[derive(Debug, Clone, Default)]
pub struct LocaleMetadata {
pub title: String,
pub description: String,
pub whats_new: String,
pub keywords: Vec<String>,
pub promotional_text: String,
}
impl LocaleMetadata {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
title: title.into(),
description: description.into(),
..Default::default()
}
}
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
self.whats_new = whats_new.into();
self
}
}
/// Complete store metadata across all locales.
#[derive(Debug, Clone, Default)]
pub struct StoreMetadata {
pub locales: HashMap<String, LocaleMetadata>,
}
impl StoreMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
self.locales.insert(locale.into(), metadata);
self
}
/// Get metadata for a specific locale, falling back to `en-US`.
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
}
/// Load metadata from the directory structure.
///
/// Falls back gracefully: if the metadata directory doesn't exist,
/// returns empty metadata (don't fail the publish for missing store copy).
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
let path = Path::new(dir);
if !path.exists() {
// Warn but don't fail — metadata is optional for internal/alpha builds.
return Ok(Self::new());
}
let mut metadata = Self::new();
let read_dir = std::fs::read_dir(path).map_err(|e| {
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
})?;
for entry in read_dir.flatten() {
let locale_path = entry.path();
if !locale_path.is_dir() {
continue;
}
let locale = locale_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if locale.is_empty() {
continue;
}
let locale_meta = LocaleMetadata {
title: read_file(&locale_path, "title.txt"),
description: read_file(&locale_path, "description.txt"),
whats_new: read_file(&locale_path, "whats-new.txt"),
keywords: read_file(&locale_path, "keywords.txt")
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect(),
promotional_text: read_file(&locale_path, "promotional-text.txt"),
};
metadata.locales.insert(locale, locale_meta);
}
Ok(metadata)
}
}
fn read_file(dir: &Path, filename: &str) -> String {
std::fs::read_to_string(dir.join(filename))
.unwrap_or_default()
.trim()
.to_string()
}
+131
View File
@@ -0,0 +1,131 @@
//! Rollout monitor — checks crash rate, advances or halts rollout.
use crate::{config::RolloutConfig, PublishError, PublishResult};
/// Current rollout state.
#[derive(Debug, Clone)]
pub struct RolloutState {
pub track: String,
pub current_percent: u8,
pub crash_rate: f64,
pub is_halted: bool,
pub hours_elapsed: u64,
}
impl RolloutState {
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
Self {
track: track.into(),
current_percent: initial_percent,
crash_rate: 0.0,
is_halted: false,
hours_elapsed: 0,
}
}
}
/// Monitors a staged rollout and decides whether to advance or halt.
pub struct RolloutMonitor {
pub config: RolloutConfig,
}
impl RolloutMonitor {
pub fn new(config: RolloutConfig) -> Self {
Self { config }
}
/// Evaluate whether to advance, halt, or maintain the current rollout.
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
if state.is_halted {
return RolloutDecision::Halted {
reason: "rollout was previously halted".into(),
};
}
// Check crash rate
if state.crash_rate > self.config.max_crash_rate {
return RolloutDecision::Halt {
reason: format!(
"crash rate {:.2}% exceeds threshold {:.2}%",
state.crash_rate * 100.0,
self.config.max_crash_rate * 100.0
),
};
}
// Check if we should advance
if self.config.auto_advance
&& state.hours_elapsed >= self.config.advance_after_hours
&& state.current_percent < 100
{
let next_percent = advance_percent(state.current_percent);
return RolloutDecision::Advance { to_percent: next_percent };
}
RolloutDecision::Maintain
}
/// Apply a rollout decision — returns the new rollout state.
pub fn apply(
&self,
mut state: RolloutState,
decision: &RolloutDecision,
) -> PublishResult<RolloutState> {
match decision {
RolloutDecision::Advance { to_percent } => {
state.current_percent = *to_percent;
state.hours_elapsed = 0;
}
RolloutDecision::Halt { reason } => {
state.is_halted = true;
let _ = reason;
}
RolloutDecision::Halted { .. } => {
return Err(PublishError::Config(
"cannot apply decision to halted rollout".into(),
));
}
RolloutDecision::Maintain => {}
}
Ok(state)
}
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
///
/// In production: fetch crash rate from Firebase Crashlytics or App Store
/// Connect Analytics API.
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
// TODO: fetch real crash rate from analytics API
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
// state.crash_rate = crash_rate;
state.hours_elapsed += 1;
let decision = self.evaluate(&state);
let new_state = self.apply(state, &decision)?;
Ok((new_state, decision))
}
}
/// The outcome of a rollout evaluation.
#[derive(Debug, Clone)]
pub enum RolloutDecision {
/// Advance to a higher rollout percentage.
Advance { to_percent: u8 },
/// Halt the rollout due to high crash rate.
Halt { reason: String },
/// Rollout was already halted.
Halted { reason: String },
/// No change needed.
Maintain,
}
/// Determine the next rollout percentage.
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
fn advance_percent(current: u8) -> u8 {
match current {
0..=9 => 10,
10..=24 => 25,
25..=49 => 50,
_ => 100,
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Screenshot pipeline — captures app screenshots for each store target size.
use crate::PublishResult;
/// A screenshot target device/size specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotTarget {
/// Display name (e.g. "iPhone 6.7\"")
pub name: String,
/// Target identifier used in el.toml (e.g. "iphone-6.7")
pub id: String,
pub width: u32,
pub height: u32,
pub platform: ScreenshotPlatform,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenshotPlatform {
Ios,
Android,
}
impl ScreenshotTarget {
/// All standard screenshot targets, matching Apple and Google requirements.
pub fn all_standard() -> Vec<Self> {
vec![
Self {
name: "iPhone 6.7\"".into(),
id: "iphone-6.7".into(),
width: 1290,
height: 2796,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPhone 6.1\"".into(),
id: "iphone-6.1".into(),
width: 1179,
height: 2556,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPad 13\"".into(),
id: "ipad-13".into(),
width: 2064,
height: 2752,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "Android Phone".into(),
id: "android-phone".into(),
width: 1080,
height: 1920,
platform: ScreenshotPlatform::Android,
},
]
}
/// Find a target by its ID.
pub fn find(id: &str) -> Option<Self> {
Self::all_standard().into_iter().find(|t| t.id == id)
}
}
/// A captured screenshot.
#[derive(Debug, Clone)]
pub struct Screenshot {
pub target: ScreenshotTarget,
pub locale: String,
pub scenario: String,
/// Path to the PNG file.
pub path: String,
}
/// Trait for screenshot capture backends.
///
/// Implementations might use:
/// - `xcrun simctl` for iOS Simulator screenshots
/// - Android Emulator ADB screenshots
/// - Puppeteer/Playwright for web screenshots
pub trait ScreenshotCapture: Send + Sync {
/// Capture a screenshot for the given scenario and target.
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot>;
/// Capture all scenarios for all targets and locales.
fn capture_all(
&self,
scenarios: &[String],
targets: &[ScreenshotTarget],
locales: &[String],
output_dir: &str,
) -> PublishResult<Vec<Screenshot>> {
let mut screenshots = Vec::new();
for scenario in scenarios {
for target in targets {
for locale in locales {
let shot = self.capture(scenario, target, locale, output_dir)?;
screenshots.push(shot);
}
}
}
Ok(screenshots)
}
}
/// Stub screenshot capture (produces placeholder paths without actually
/// launching a simulator). A future agent fills in the real capture logic.
pub struct StubScreenshotCapture;
impl ScreenshotCapture for StubScreenshotCapture {
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot> {
let filename = format!(
"{}/{}/{}-{}.png",
output_dir, locale, target.id, scenario
);
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
Ok(Screenshot {
target: target.clone(),
locale: locale.to_string(),
scenario: scenario.to_string(),
path: filename,
})
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Tests for el-publish.
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
cert::{CertInfo, CertStore},
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
metadata::{LocaleMetadata, StoreMetadata},
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
PublishError,
};
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn test_config() -> PublishConfig {
PublishConfig::new("1.2.0", 42)
.with_apple(AppleConfig::new(
"will@example.com",
"TEAM123",
"ai.example.myapp",
))
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
}
// ── Test 1: PublishConfig parses version and build number ─────────────────
#[test]
fn test_publish_config_basic() {
let cfg = test_config();
assert_eq!(cfg.version, "1.2.0");
assert_eq!(cfg.build_number, 42);
assert!(cfg.apple.is_some());
assert!(cfg.google.is_some());
}
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
#[test]
fn test_apple_config() {
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
.with_category("utilities");
assert_eq!(cfg.account, "will@example.com");
assert_eq!(cfg.team_id, "TEAM123");
assert_eq!(cfg.category, "utilities");
}
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
#[test]
fn test_google_config_default_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
assert_eq!(cfg.track, "internal");
}
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
#[test]
fn test_google_config_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
.with_track("production");
assert_eq!(cfg.track, "production");
}
// ── Test 5: RolloutConfig default values ─────────────────────────────────
#[test]
fn test_rollout_config_default() {
let cfg = RolloutConfig::default();
assert_eq!(cfg.initial_percent, 100);
assert!(!cfg.auto_advance);
assert_eq!(cfg.max_crash_rate, 0.01);
}
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
#[test]
fn test_rollout_maintain() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001, // well below threshold
is_halted: false,
hours_elapsed: 5, // less than 24h advance threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Maintain));
}
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
#[test]
fn test_rollout_halt_on_crash_rate() {
let cfg = RolloutConfig::new(10);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.05, // 5% > 1% threshold
is_halted: false,
hours_elapsed: 2,
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Halt { .. }));
}
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
#[test]
fn test_rollout_advance() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 25, // exceeds 24h threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
}
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
#[test]
fn test_rollout_advance_progression() {
let cfg = RolloutConfig::new(10).with_auto_advance(1);
let monitor = RolloutMonitor::new(cfg);
let mut state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 5,
};
let expected = [25u8, 50, 100];
for expected_next in expected {
let decision = monitor.evaluate(&state);
if let RolloutDecision::Advance { to_percent } = decision {
state.current_percent = to_percent;
state.hours_elapsed = 5;
assert_eq!(to_percent, expected_next);
} else {
panic!("expected Advance decision, got {:?}", decision);
}
}
}
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
#[test]
fn test_cert_days_until_expiry() {
let future = unix_now() + 30 * 86400; // 30 days from now
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
let days = cert.days_until_expiry();
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
}
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
#[test]
fn test_cert_is_expired() {
let past = unix_now() - 86400; // yesterday
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
assert!(cert.is_expired());
assert_eq!(cert.days_until_expiry(), 0);
}
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
#[test]
fn test_cert_store_renewal_warnings() {
let mut store = CertStore::new().with_warn_days(45);
let soon = unix_now() + 10 * 86400; // 10 days away
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
let warnings = store.renewal_warnings();
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
}
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
#[test]
fn test_cert_store_validate_no_cert() {
let store = CertStore::new();
let result = store.validate_for_distribution("TEAM123");
assert!(result.is_err());
match result {
Err(PublishError::Certificate(_)) => {}
_ => panic!("expected Certificate error"),
}
}
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
#[test]
fn test_store_metadata_locale_fallback() {
let meta = StoreMetadata::new()
.add_locale(
"en-US",
LocaleMetadata::new("My App", "The best app ever"),
);
// de-DE not set, should fall back to en-US
let locale_meta = meta.for_locale("de-DE").unwrap();
assert_eq!(locale_meta.title, "My App");
}
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
#[test]
fn test_store_metadata_missing_dir() {
// Missing directory should not error — returns empty metadata
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
assert!(result.is_ok());
}
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
#[test]
fn test_screenshot_targets() {
let targets = ScreenshotTarget::all_standard();
assert!(targets.len() >= 4, "should have at least 4 standard targets");
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"iphone-6.7"));
assert!(ids.contains(&"android-phone"));
}
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
#[test]
fn test_screenshot_target_find() {
let target = ScreenshotTarget::find("ipad-13").unwrap();
assert_eq!(target.name, "iPad 13\"");
assert_eq!(target.width, 2064);
}
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
#[test]
fn test_stub_screenshot_capture() {
let capture = StubScreenshotCapture;
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
assert!(shot.path.contains("iphone-6.7"));
assert!(shot.path.contains("home_screen"));
assert_eq!(shot.locale, "en-US");
}
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
#[test]
fn test_publish_config_with_rollout() {
let rollout = RolloutConfig::new(10)
.with_auto_advance(24)
.with_max_crash_rate(0.005);
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
let r = cfg.rollout.unwrap();
assert_eq!(r.initial_percent, 10);
assert!(r.auto_advance);
assert_eq!(r.max_crash_rate, 0.005);
}
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
#[test]
fn test_publish_config_screenshot_targets() {
let cfg = PublishConfig::new("1.0.0", 1)
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
assert_eq!(cfg.screenshot_targets.len(), 2);
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
}
}

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