Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -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]
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms);
|
||||
assert!(aspect.is_some(), "should create authorize aspect");
|
||||
assert_eq!(aspect.unwrap().name(), "authorize");
|
||||
}
|
||||
|
||||
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
|
||||
#[test]
|
||||
fn test_registry_unknown_aspect() {
|
||||
let registry = AspectRegistry::with_builtins();
|
||||
let result = registry.create("unknown_aspect", &HashMap::new());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
|
||||
#[test]
|
||||
fn test_cache_zero_ttl() {
|
||||
let aspect = CacheAspect::new(0); // Immediate expiry
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let nc = n.clone();
|
||||
// Both calls should hit proceed since ttl=0 means instant expiry
|
||||
let p1: crate::ProceedFn = Box::new(move |_ctx| {
|
||||
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(InvocationResult::new(v.to_string()))
|
||||
});
|
||||
// Zero TTL will expire immediately; we just verify it doesn't panic
|
||||
let _ = aspect.around(ctx.clone(), &p1);
|
||||
// Second call should also execute proceed
|
||||
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
|
||||
let r = aspect.around(ctx, &p2).unwrap();
|
||||
assert_eq!(r.value, "fresh");
|
||||
}
|
||||
|
||||
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
|
||||
#[test]
|
||||
fn test_empty_chain_calls_proceed() {
|
||||
let chain = AspectChain::new();
|
||||
assert!(chain.is_empty());
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
|
||||
assert_eq!(result.value, "direct");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,22 @@
|
||||
// el-auth — Built-in authentication and authorization for el-ui.
|
||||
//
|
||||
// Not a library you add. Native to the framework. Sits on top of el-identity:
|
||||
// el-identity owns the graph (users, sessions, OAuth tokens); el-auth owns the
|
||||
// request-time enforcement (verify token, check permission, enforce roles).
|
||||
|
||||
vessel "el-auth" {
|
||||
version "0.1.0"
|
||||
description "Built-in auth/authz: JWT, sessions, RBAC, middleware"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-identity "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 `manifest.el`).
|
||||
//! This implementation uses in-memory storage for simplicity and testing.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct SessionEntry {
|
||||
context: AuthContext,
|
||||
created_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory session store.
|
||||
pub struct SessionProvider {
|
||||
sessions: Mutex<HashMap<String, SessionEntry>>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
ttl: Duration::from_secs(3600),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: u64) -> Self {
|
||||
self.ttl = Duration::from_secs(seconds);
|
||||
self
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890)
|
||||
}
|
||||
|
||||
/// Count active (non-expired) sessions.
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
let sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
sessions.values().filter(|s| !s.is_expired()).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for SessionProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"session"
|
||||
}
|
||||
|
||||
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
|
||||
let mut sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
// Clean expired sessions
|
||||
sessions.retain(|_, v| !v.is_expired());
|
||||
sessions
|
||||
.get(session_id)
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| s.context.clone())
|
||||
.ok_or(AuthError::SessionNotFound)
|
||||
}
|
||||
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
let session_id = Self::generate_session_id();
|
||||
let ctx = AuthContext::authenticated(user, Vec::new(), &session_id);
|
||||
let entry = SessionEntry {
|
||||
context: ctx,
|
||||
created_at: Instant::now(),
|
||||
ttl: self.ttl,
|
||||
};
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.insert(session_id.clone(), entry);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
fn revoke(&self, session_id: &str) -> AuthResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.remove(session_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Tests for el-auth.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
context::{AuthContext, AuthUser},
|
||||
jwt::{JwtClaims, JwtProvider},
|
||||
middleware::AuthMiddleware,
|
||||
roles::{Permission, Role, RoleRegistry},
|
||||
session::SessionProvider,
|
||||
AuthError, AuthProvider,
|
||||
};
|
||||
|
||||
fn test_user() -> AuthUser {
|
||||
AuthUser::new("user-1", "alice@example.com", "Alice")
|
||||
}
|
||||
|
||||
fn test_provider() -> JwtProvider {
|
||||
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
|
||||
.with_ttl(3600)
|
||||
}
|
||||
|
||||
fn registry() -> RoleRegistry {
|
||||
let mut r = RoleRegistry::new();
|
||||
r.register(
|
||||
Role::new("admin")
|
||||
.with_permissions(vec!["read", "write", "delete"]),
|
||||
);
|
||||
r.register(
|
||||
Role::new("user")
|
||||
.with_permissions(vec!["read"]),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_sign_and_verify() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["user"]);
|
||||
}
|
||||
|
||||
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_invalid_signature() {
|
||||
let provider = test_provider();
|
||||
let other_provider = JwtProvider::new(b"different-secret".to_vec());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec![], 3600);
|
||||
let token = other_provider.encode(&claims);
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 3: JWT expired token is rejected ────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_expired_token() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
// TTL of 0 — expires immediately
|
||||
let claims = JwtClaims::new(&user, vec![], 0);
|
||||
let token = provider.encode(&claims);
|
||||
// Wait a moment (in tests, just check the claims are_expired)
|
||||
assert!(claims.is_expired() || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
true
|
||||
});
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
|
||||
#[test]
|
||||
fn test_jwt_verify_returns_auth_context() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let reg = registry();
|
||||
let token = provider.issue(user, ®).unwrap();
|
||||
let ctx = provider.verify(&token).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_role() {
|
||||
let ctx = AuthContext::authenticated(
|
||||
test_user(),
|
||||
vec!["admin".into(), "user".into()],
|
||||
"tok",
|
||||
);
|
||||
assert!(ctx.has_role("admin"));
|
||||
assert!(ctx.has_role("user"));
|
||||
assert!(!ctx.has_role("superadmin"));
|
||||
}
|
||||
|
||||
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_permission() {
|
||||
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
|
||||
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
|
||||
assert!(ctx.has_permission("read"));
|
||||
assert!(ctx.has_permission("delete"));
|
||||
assert!(!ctx.has_permission("sudo"));
|
||||
}
|
||||
|
||||
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
|
||||
#[test]
|
||||
fn test_anonymous_context() {
|
||||
let ctx = AuthContext::anonymous();
|
||||
assert!(!ctx.is_authenticated());
|
||||
assert!(ctx.user_id().is_none());
|
||||
}
|
||||
|
||||
// ── Test 8: Role has_permission ──────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_role_has_permission() {
|
||||
let role = Role::new("editor")
|
||||
.with_permissions(vec!["read", "write"]);
|
||||
assert!(role.has_permission("read"));
|
||||
assert!(role.has_permission("write"));
|
||||
assert!(!role.has_permission("delete"));
|
||||
}
|
||||
|
||||
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
|
||||
#[test]
|
||||
fn test_role_registry_permission_check() {
|
||||
let reg = registry();
|
||||
let roles = vec!["user".to_string()];
|
||||
assert!(reg.has_permission(&roles, "read"));
|
||||
assert!(!reg.has_permission(&roles, "delete"));
|
||||
|
||||
let admin_roles = vec!["admin".to_string()];
|
||||
assert!(reg.has_permission(&admin_roles, "delete"));
|
||||
}
|
||||
|
||||
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
|
||||
#[test]
|
||||
fn test_session_issue_and_verify() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
let ctx = provider.verify(&session_id).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 11: SessionProvider revoke removes session ───────────────────────
|
||||
#[test]
|
||||
fn test_session_revoke() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
provider.revoke(&session_id).unwrap();
|
||||
let result = provider.verify(&session_id);
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 12: SessionProvider unknown session returns error ────────────────
|
||||
#[test]
|
||||
fn test_session_unknown() {
|
||||
let provider = SessionProvider::new();
|
||||
let result = provider.verify("nonexistent-session-id");
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
|
||||
#[test]
|
||||
fn test_middleware_extracts_bearer_token() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let header = format!("Bearer {}", token);
|
||||
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
|
||||
#[test]
|
||||
fn test_middleware_no_header_anonymous() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let ctx = middleware.authenticate_from_header(None).unwrap();
|
||||
assert!(!ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 15: Permission Display ───────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_permission_display() {
|
||||
let perm = Permission::new("write");
|
||||
assert_eq!(perm.to_string(), "write");
|
||||
assert_eq!(perm.as_str(), "write");
|
||||
}
|
||||
|
||||
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
|
||||
#[test]
|
||||
fn test_jwt_claims_json_round_trip() {
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let json = claims.to_json();
|
||||
let decoded = JwtClaims::from_json(&json).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_multiple_roles() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.roles.len(), 2);
|
||||
assert!(decoded.roles.contains(&"admin".to_string()));
|
||||
}
|
||||
|
||||
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
|
||||
#[test]
|
||||
fn test_role_registry_names() {
|
||||
let reg = registry();
|
||||
let mut names = reg.role_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
|
||||
#[test]
|
||||
fn test_session_ttl_config() {
|
||||
let provider = SessionProvider::new().with_ttl(7200);
|
||||
assert_eq!(provider.ttl.as_secs(), 7200);
|
||||
}
|
||||
|
||||
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
|
||||
#[test]
|
||||
fn test_middleware_provider_name() {
|
||||
let jwt_provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(jwt_provider);
|
||||
assert_eq!(middleware.provider_name(), "jwt");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-config — Layered, typed configuration for el-ui applications.
|
||||
//
|
||||
// Resolution order (highest priority wins):
|
||||
// 1. Environment variables (EL_APP_NAME=...)
|
||||
// 2. .env file (development only)
|
||||
// 3. manifest.el [env.<current>] section
|
||||
// 4. manifest.el [config] base section
|
||||
// 5. Defaults defined in code
|
||||
|
||||
vessel "el-config" {
|
||||
version "0.1.0"
|
||||
description "Layered, typed configuration with manifest + env + defaults"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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. manifest.el [env.<current>] section
|
||||
/// 4. manifest.el [config] section (base)
|
||||
/// 5. Defaults defined in code (lowest)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::ConfigError;
|
||||
use crate::source::{ConfigSource, FromConfigStr, MapSource};
|
||||
use crate::env::Environment;
|
||||
|
||||
/// The main configuration object.
|
||||
///
|
||||
/// Holds a stack of sources and resolves keys through them in order.
|
||||
pub struct Config {
|
||||
/// Sources in descending priority order (index 0 = highest priority).
|
||||
sources: Vec<Box<dyn ConfigSource>>,
|
||||
/// Environment in effect.
|
||||
pub environment: Environment,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create an empty config with no sources.
|
||||
pub fn new(env: Environment) -> Self {
|
||||
Self {
|
||||
sources: Vec::new(),
|
||||
environment: env,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build with the default source stack for an application:
|
||||
/// env vars > defaults map.
|
||||
pub fn default_stack() -> Self {
|
||||
use crate::source::EnvVarSource;
|
||||
let env = Environment::current();
|
||||
let mut config = Self::new(env);
|
||||
config.push_source(Box::new(EnvVarSource::new()));
|
||||
config
|
||||
}
|
||||
|
||||
/// Add a source at the lowest priority (end of the stack).
|
||||
pub fn push_source(&mut self, source: Box<dyn ConfigSource>) {
|
||||
self.sources.push(source);
|
||||
}
|
||||
|
||||
/// Add a source at the highest priority (beginning of the stack).
|
||||
pub fn prepend_source(&mut self, source: Box<dyn ConfigSource>) {
|
||||
self.sources.insert(0, source);
|
||||
}
|
||||
|
||||
/// Add defaults as the lowest-priority source.
|
||||
pub fn set_defaults(&mut self, defaults: HashMap<String, String>) {
|
||||
let src = MapSource::from_map("defaults", defaults);
|
||||
self.sources.push(Box::new(src));
|
||||
}
|
||||
|
||||
/// Get a raw string value for a key.
|
||||
pub fn get_raw(&self, key: &str) -> Option<String> {
|
||||
for source in &self.sources {
|
||||
if let Some(val) = source.get_raw(key) {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a typed value for a key.
|
||||
///
|
||||
/// Returns an error if the key is not found or can't be parsed.
|
||||
pub fn get<T: FromConfigStr>(&self, key: &str) -> Result<T, ConfigError> {
|
||||
let raw = self.get_raw(key).ok_or_else(|| ConfigError::NotFound {
|
||||
key: key.to_string(),
|
||||
})?;
|
||||
|
||||
T::from_config_str(&raw).map_err(|e| {
|
||||
// Inject the key into TypeMismatch errors
|
||||
match e {
|
||||
ConfigError::TypeMismatch { expected, got, .. } => {
|
||||
ConfigError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
expected,
|
||||
got,
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a typed value with a fallback default.
|
||||
pub fn get_or<T: FromConfigStr>(&self, key: &str, default: T) -> T {
|
||||
self.get(key).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Get an optional typed value. Returns None if not set (not an error).
|
||||
pub fn get_opt<T: FromConfigStr>(&self, key: &str) -> Result<Option<T>, ConfigError> {
|
||||
match self.get_raw(key) {
|
||||
None => Ok(None),
|
||||
Some(raw) => T::from_config_str(&raw)
|
||||
.map(Some)
|
||||
.map_err(|e| match e {
|
||||
ConfigError::TypeMismatch { expected, got, .. } => {
|
||||
ConfigError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
expected,
|
||||
got,
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// All key→value pairs from all sources (merged, highest-priority wins).
|
||||
pub fn all(&self) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
// Iterate in reverse order (lowest priority first) so higher-priority
|
||||
// sources overwrite lower-priority ones.
|
||||
for source in self.sources.iter().rev() {
|
||||
for (k, v) in source.all() {
|
||||
result.insert(k, v);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from an `manifest.el` string.
|
||||
///
|
||||
/// Reads `[config]` as the base, then overlays `[env.<environment>]`.
|
||||
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
|
||||
let value: toml::Value = toml::from_str(toml_str)
|
||||
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Load base [config] section
|
||||
if let Some(config_section) = value.get("config") {
|
||||
if let Some(table) = config_section.as_table() {
|
||||
flatten_toml_table(table, "", &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay [env.<name>] section
|
||||
let env_key = env.name();
|
||||
if let Some(env_sections) = value.get("env") {
|
||||
if let Some(env_table) = env_sections.get(env_key) {
|
||||
if let Some(table) = env_table.as_table() {
|
||||
flatten_toml_table(table, "", &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapSource::from_map("manifest.el", map))
|
||||
}
|
||||
|
||||
fn flatten_toml_table(
|
||||
table: &toml::value::Table,
|
||||
prefix: &str,
|
||||
out: &mut HashMap<String, String>,
|
||||
) {
|
||||
for (key, value) in table {
|
||||
let full_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
toml::Value::String(s) => {
|
||||
out.insert(full_key, s.clone());
|
||||
}
|
||||
toml::Value::Integer(i) => {
|
||||
out.insert(full_key, i.to_string());
|
||||
}
|
||||
toml::Value::Float(f) => {
|
||||
out.insert(full_key, f.to_string());
|
||||
}
|
||||
toml::Value::Boolean(b) => {
|
||||
out.insert(full_key, b.to_string());
|
||||
}
|
||||
toml::Value::Table(t) => {
|
||||
flatten_toml_table(t, &full_key, out);
|
||||
}
|
||||
_ => {} // Arrays, datetimes: skip for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro for typed config access on a global/injected Config.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let name = config!(cfg, "app.name", String);
|
||||
/// let port = config!(cfg, "server.port", u32, 8080);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! config {
|
||||
($cfg:expr, $key:expr, $type:ty) => {
|
||||
$cfg.get::<$type>($key)
|
||||
};
|
||||
($cfg:expr, $key:expr, $type:ty, $default:expr) => {
|
||||
$cfg.get_or::<$type>($key, $default)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::MapSource;
|
||||
|
||||
fn make_config(pairs: &[(&str, &str)]) -> Config {
|
||||
let mut src = MapSource::new("test");
|
||||
for (k, v) in pairs {
|
||||
src.insert(*k, *v);
|
||||
}
|
||||
let mut cfg = Config::new(Environment::Development);
|
||||
cfg.push_source(Box::new(src));
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_string() {
|
||||
let cfg = make_config(&[("app.name", "TestApp")]);
|
||||
assert_eq!(cfg.get::<String>("app.name").unwrap(), "TestApp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_u32() {
|
||||
let cfg = make_config(&[("server.port", "8080")]);
|
||||
assert_eq!(cfg.get::<u32>("server.port").unwrap(), 8080u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_bool() {
|
||||
let cfg = make_config(&[("feature.enabled", "true")]);
|
||||
assert_eq!(cfg.get::<bool>("feature.enabled").unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_error() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert!(cfg.get::<String>("missing.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_default() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert_eq!(cfg.get_or("timeout", 30u32), 30u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_prefers_source() {
|
||||
let cfg = make_config(&[("timeout", "60")]);
|
||||
assert_eq!(cfg.get_or("timeout", 30u32), 60u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_priority_source_wins() {
|
||||
let mut cfg = Config::new(Environment::Development);
|
||||
let mut low = MapSource::new("low");
|
||||
low.insert("key", "low-value");
|
||||
let mut high = MapSource::new("high");
|
||||
high.insert("key", "high-value");
|
||||
cfg.push_source(Box::new(high));
|
||||
cfg.push_source(Box::new(low));
|
||||
// First source (index 0) is highest priority
|
||||
assert_eq!(cfg.get::<String>("key").unwrap(), "high-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_opt_missing_is_none() {
|
||||
let cfg = Config::new(Environment::Development);
|
||||
assert_eq!(cfg.get_opt::<String>("missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_opt_present_is_some() {
|
||||
let cfg = make_config(&[("key", "value")]);
|
||||
assert_eq!(
|
||||
cfg.get_opt::<String>("key").unwrap(),
|
||||
Some("value".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_base() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
app.name = "MyApp"
|
||||
app.version = "1.0.0"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Development).unwrap();
|
||||
assert_eq!(src.get_raw("app.name"), Some("MyApp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_env_overlay() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
api.base_url = "https://api.example.com"
|
||||
|
||||
[env.development]
|
||||
api.base_url = "http://localhost:8080"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Development).unwrap();
|
||||
assert_eq!(
|
||||
src.get_raw("api.base_url"),
|
||||
Some("http://localhost:8080".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_env_does_not_override_in_prod() {
|
||||
let toml = r#"
|
||||
[config]
|
||||
api.base_url = "https://api.example.com"
|
||||
|
||||
[env.development]
|
||||
api.base_url = "http://localhost:8080"
|
||||
"#;
|
||||
let src = load_from_toml(toml, &Environment::Production).unwrap();
|
||||
assert_eq!(
|
||||
src.get_raw("api.base_url"),
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_toml_invalid() {
|
||||
let result = load_from_toml("not valid toml %%%", &Environment::Development);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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. `manifest.el` `[env.<current>]` section
|
||||
//! 4. `manifest.el` `[config]` base section
|
||||
//! 5. Defaults defined in code
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use el_config::prelude::*;
|
||||
//!
|
||||
//! let mut cfg = Config::new(Environment::Development);
|
||||
//! let mut defaults = std::collections::HashMap::new();
|
||||
//! defaults.insert("app.name".to_string(), "MyApp".to_string());
|
||||
//! defaults.insert("server.port".to_string(), "8080".to_string());
|
||||
//! cfg.set_defaults(defaults);
|
||||
//!
|
||||
//! let name = cfg.get::<String>("app.name").unwrap();
|
||||
//! let port = cfg.get::<u32>("server.port").unwrap();
|
||||
//! assert_eq!(name, "MyApp");
|
||||
//! assert_eq!(port, 8080);
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod config;
|
||||
pub mod env;
|
||||
pub mod error;
|
||||
pub mod source;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::config::{load_from_toml, Config};
|
||||
pub use crate::env::Environment;
|
||||
pub use crate::error::ConfigError;
|
||||
pub use crate::source::{ConfigSource, EnvVarSource, FromConfigStr, MapSource};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -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"))
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,19 @@
|
||||
// el-i18n — Localization for el-ui.
|
||||
//
|
||||
// RTL-aware, plural forms, CLDR-based number / currency formatting.
|
||||
|
||||
vessel "el-i18n" {
|
||||
version "0.1.0"
|
||||
description "Locales, translations, plural forms, number/currency formatting"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" => ("zł", false, 2),
|
||||
"TRY" => ("₺", true, 2),
|
||||
"SAR" => ("﷼", false, 2),
|
||||
"AED" => ("د.إ", false, 2),
|
||||
_ => ("¤", true, 2), // generic currency sign for unknown codes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_number_en_us() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_de() {
|
||||
let locale = Locale::new("de-DE");
|
||||
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_no_decimals() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(42.0, &locale, 0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_negative() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_usd() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_currency_jpy_no_decimals() {
|
||||
let locale = Locale::ja();
|
||||
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_groups() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(1000000, &locale), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_integer_small() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_integer(42, &locale), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_whole() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.85, &locale, 0), "85%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_percent_with_decimal() {
|
||||
let locale = Locale::en_us();
|
||||
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -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. 2–4 in Slavic languages).
|
||||
Two,
|
||||
/// Few (language-specific).
|
||||
Few,
|
||||
/// Many (language-specific).
|
||||
Many,
|
||||
/// The catch-all / default.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Determine the plural form for a count in a given language.
|
||||
///
|
||||
/// Rules are a simplified implementation of CLDR plural rules for the most
|
||||
/// common languages. The full CLDR spec covers hundreds of languages; these
|
||||
/// cover the major cases.
|
||||
pub fn plural_form(language: &str, count: i64) -> PluralForm {
|
||||
let n = count.unsigned_abs(); // absolute value for matching
|
||||
|
||||
match language {
|
||||
// English and most Western European languages: one / other
|
||||
"en" | "de" | "nl" | "sv" | "da" | "no" | "nb" | "nn" | "fi"
|
||||
| "et" | "hu" | "tr" | "pt" | "it" | "es" | "ca" | "el" | "id"
|
||||
| "ms" | "th" | "zh" | "ja" | "ko" | "vi" | "ur" => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// French: one for 0 and 1, other for rest
|
||||
"fr" => {
|
||||
if n <= 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
|
||||
// Russian, Ukrainian, Belarusian: complex Slavic rules
|
||||
"ru" | "uk" | "be" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n10 == 1 && n100 != 11 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Polish: similar Slavic rules
|
||||
"pl" => {
|
||||
let n10 = n % 10;
|
||||
let n100 = n % 100;
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Many
|
||||
}
|
||||
}
|
||||
|
||||
// Czech, Slovak
|
||||
"cs" | "sk" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if (2..=4).contains(&n) {
|
||||
PluralForm::Few
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Arabic: 6 forms
|
||||
"ar" => {
|
||||
let n100 = n % 100;
|
||||
if n == 0 {
|
||||
PluralForm::Zero
|
||||
} else if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if (3..=10).contains(&n100) {
|
||||
PluralForm::Few
|
||||
} else if (11..=99).contains(&n100) {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Hebrew
|
||||
"he" | "iw" => {
|
||||
if n == 1 {
|
||||
PluralForm::One
|
||||
} else if n == 2 {
|
||||
PluralForm::Two
|
||||
} else if n >= 11 && n % 10 == 0 {
|
||||
PluralForm::Many
|
||||
} else {
|
||||
PluralForm::Other
|
||||
}
|
||||
}
|
||||
|
||||
// Default: one / other
|
||||
_ => {
|
||||
if n == 1 { PluralForm::One } else { PluralForm::Other }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn english_one() {
|
||||
assert_eq!(plural_form("en", 1), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_other() {
|
||||
assert_eq!(plural_form("en", 0), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 2), PluralForm::Other);
|
||||
assert_eq!(plural_form("en", 100), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_zero_is_one() {
|
||||
assert_eq!(plural_form("fr", 0), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("fr", 2), PluralForm::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_one() {
|
||||
assert_eq!(plural_form("ru", 1), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 21), PluralForm::One);
|
||||
assert_eq!(plural_form("ru", 101), PluralForm::One);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_few() {
|
||||
assert_eq!(plural_form("ru", 2), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 3), PluralForm::Few);
|
||||
assert_eq!(plural_form("ru", 22), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_many() {
|
||||
assert_eq!(plural_form("ru", 5), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 11), PluralForm::Many);
|
||||
assert_eq!(plural_form("ru", 20), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_zero() {
|
||||
assert_eq!(plural_form("ar", 0), PluralForm::Zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_two() {
|
||||
assert_eq!(plural_form("ar", 2), PluralForm::Two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_few() {
|
||||
assert_eq!(plural_form("ar", 5), PluralForm::Few);
|
||||
assert_eq!(plural_form("ar", 10), PluralForm::Few);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_many() {
|
||||
assert_eq!(plural_form("ar", 15), PluralForm::Many);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_two() {
|
||||
assert_eq!(plural_form("he", 2), PluralForm::Two);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity is not a bolt-on. Users, roles, scopes, sessions, and OAuth tokens
|
||||
// are first-class Engram nodes connected by typed edges:
|
||||
//
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// │
|
||||
// └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
vessel "el-identity" {
|
||||
version "0.1.0"
|
||||
description "Engram-native identity graph: users, roles, sessions, OAuth"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 + ")")
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret);
|
||||
params.insert("refresh_token", refresh_token);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "profile"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppleOAuth ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apple Sign In OAuth provider.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://appleid.apple.com/auth/authorize`
|
||||
/// - Token: `https://appleid.apple.com/auth/token`
|
||||
///
|
||||
/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key.
|
||||
/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleOAuth {
|
||||
pub client_id: String,
|
||||
/// The JWT client secret (pre-generated, rotated manually or via automation).
|
||||
pub client_secret_jwt: String,
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
impl AppleOAuth {
|
||||
pub fn new(
|
||||
client_id: impl Into<String>,
|
||||
client_secret_jwt: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client_id: client_id.into(),
|
||||
client_secret_jwt: client_secret_jwt.into(),
|
||||
team_id: team_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize";
|
||||
const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token";
|
||||
}
|
||||
|
||||
impl OAuthProvider for AppleOAuth {
|
||||
fn name(&self) -> &'static str {
|
||||
"apple"
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
pkce_challenge: &str,
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String {
|
||||
let mut scopes = self.default_scopes();
|
||||
scopes.extend_from_slice(extra_scopes);
|
||||
let scope_str = scopes.join(" ");
|
||||
format!(
|
||||
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post",
|
||||
Self::AUTH_URL,
|
||||
url_encode(&self.client_id),
|
||||
url_encode(redirect_uri),
|
||||
url_encode(&scope_str),
|
||||
url_encode(state),
|
||||
url_encode(pkce_challenge),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret_jwt);
|
||||
params.insert("code", code);
|
||||
params.insert("code_verifier", pkce_verifier);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret_jwt);
|
||||
params.insert("refresh_token", refresh_token);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "name"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── GithubOAuth ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// GitHub OAuth 2.0 provider.
|
||||
///
|
||||
/// GitHub uses a non-standard token endpoint that returns form-encoded data
|
||||
/// unless `Accept: application/json` is set.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://github.com/login/oauth/authorize`
|
||||
/// - Token: `https://github.com/login/oauth/access_token`
|
||||
///
|
||||
/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubOAuth {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
impl GithubOAuth {
|
||||
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client_id: client_id.into(),
|
||||
client_secret: client_secret.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize";
|
||||
const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
|
||||
}
|
||||
|
||||
impl OAuthProvider for GithubOAuth {
|
||||
fn name(&self) -> &'static str {
|
||||
"github"
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
_pkce_challenge: &str, // GitHub does not support PKCE
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String {
|
||||
let mut scopes = self.default_scopes();
|
||||
scopes.extend_from_slice(extra_scopes);
|
||||
let scope_str = scopes.join(" ");
|
||||
format!(
|
||||
"{}?client_id={}&redirect_uri={}&scope={}&state={}",
|
||||
Self::AUTH_URL,
|
||||
url_encode(&self.client_id),
|
||||
url_encode(redirect_uri),
|
||||
url_encode(&scope_str),
|
||||
url_encode(state),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
_pkce_verifier: &str, // GitHub does not support PKCE
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", self.client_id.as_str());
|
||||
params.insert("client_secret", self.client_secret.as_str());
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
// GitHub access tokens don't expire by default and don't have refresh tokens.
|
||||
Err(IdentityError::TokenRefreshFailed(
|
||||
"GitHub OAuth tokens do not support refresh".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["user:email", "read:user"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL encoding ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn url_encode(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char);
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
b => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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"
|
||||
}
|
||||
|
||||
// ── 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"))
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-platform"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui platform rendering backends — same component code, every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_platform"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,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(())
|
||||
}
|
||||
}
|
||||
@@ -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 `manifest.el`) differs.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
/// Server-side rendering backend.
|
||||
///
|
||||
/// Renders component trees to full HTML strings. No DOM, no browser APIs.
|
||||
/// An axum handler uses this backend to generate the initial page HTML.
|
||||
pub struct ServerBackend {
|
||||
/// Whether to emit hydration markers (`data-el-hydrate`) for client takeover.
|
||||
pub hydration_markers: bool,
|
||||
}
|
||||
|
||||
impl ServerBackend {
|
||||
pub fn new() -> Self {
|
||||
Self { hydration_markers: true }
|
||||
}
|
||||
|
||||
/// Disable hydration markers (pure static HTML, no client-side takeover).
|
||||
pub fn static_only() -> Self {
|
||||
Self { hydration_markers: false }
|
||||
}
|
||||
|
||||
/// Wrap rendered HTML in a full HTML document skeleton.
|
||||
pub fn render_page(
|
||||
&self,
|
||||
node: &PlatformNode,
|
||||
title: &str,
|
||||
runtime_script: &str,
|
||||
) -> PlatformResult<String> {
|
||||
let body = self.render_to_string(node)?;
|
||||
Ok(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-el-ssr="true">
|
||||
{body}
|
||||
</div>
|
||||
<script type="module" src="{runtime_script}"></script>
|
||||
</body>
|
||||
</html>"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for ServerBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"server"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::element(tag))
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::text(content))
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: replace_child index {} out of bounds",
|
||||
index
|
||||
)));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// On the server, event handlers are emitted as data attributes.
|
||||
// The client-side hydration pass picks them up and binds real listeners.
|
||||
if self.hydration_markers {
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-el-{}", event), "hydrate"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// Server has no mount concept — rendering is one-shot.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// Server rendering is stateless — no patch needed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_ssr(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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("<script>"), "should contain escaped form");
|
||||
}
|
||||
|
||||
// ── Test 11: Void elements render correctly ───────────────────────────────
|
||||
#[test]
|
||||
fn test_void_elements() {
|
||||
let node = PlatformNode::element("input").with_attr("type", "text");
|
||||
let html = node.to_html();
|
||||
assert!(html.contains("<input"), "should have input");
|
||||
assert!(!html.contains("</input>"), "void element should not have closing tag");
|
||||
assert!(html.contains("/>"), "should self-close");
|
||||
}
|
||||
|
||||
// ── Test 12: Fragment node renders children inline ────────────────────────
|
||||
#[test]
|
||||
fn test_fragment_node() {
|
||||
let frag = PlatformNode::fragment()
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
|
||||
let html = frag.to_html();
|
||||
assert!(html.contains("<span>A</span>"));
|
||||
assert!(html.contains("<span>B</span>"));
|
||||
// No wrapping element
|
||||
assert!(!html.starts_with('<') || html.starts_with("<span>"));
|
||||
}
|
||||
|
||||
// ── Test 13: WebBackend remove_child works ───────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_remove_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("first"))
|
||||
.unwrap();
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("second"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children.len(), 2);
|
||||
|
||||
backend.remove_child(&mut parent, 0).unwrap();
|
||||
assert_eq!(parent.children.len(), 1);
|
||||
assert_eq!(parent.children[0].text_content(), Some("second"));
|
||||
}
|
||||
|
||||
// ── Test 14: WebBackend replace_child works ──────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_replace_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("old"))
|
||||
.unwrap();
|
||||
backend
|
||||
.replace_child(&mut parent, 0, PlatformNode::text("new"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children[0].text_content(), Some("new"));
|
||||
}
|
||||
|
||||
// ── Test 15: backend_for() returns correct backend names ─────────────────
|
||||
#[test]
|
||||
fn test_backend_for_names() {
|
||||
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
|
||||
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
|
||||
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
|
||||
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
|
||||
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
|
||||
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
|
||||
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
|
||||
}
|
||||
|
||||
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
|
||||
#[test]
|
||||
fn test_web_backend_bind_event() {
|
||||
let backend = WebBackend::new();
|
||||
let mut btn = backend.create_element("button").unwrap();
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
|
||||
assert!(has_attr, "should emit data-el-click attribute");
|
||||
}
|
||||
|
||||
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
|
||||
#[test]
|
||||
fn test_server_backend_bind_event() {
|
||||
let backend = ServerBackend::new();
|
||||
let mut btn = PlatformNode::element("button");
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
|
||||
assert!(has_attr, "should emit hydration marker for click event");
|
||||
}
|
||||
|
||||
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
|
||||
#[test]
|
||||
fn test_web_backend_supports_ssr() {
|
||||
// Web backend supports ssr (renders to HTML for hydration)
|
||||
let backend = WebBackend::new();
|
||||
assert!(backend.supports_ssr());
|
||||
}
|
||||
|
||||
// ── Test 19: remove_child out of bounds returns error ────────────────────
|
||||
#[test]
|
||||
fn test_remove_child_out_of_bounds() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
let result = backend.remove_child(&mut parent, 5);
|
||||
assert!(result.is_err(), "out-of-bounds remove should return error");
|
||||
}
|
||||
|
||||
// ── Test 20: All native backends render to HTML for testing ──────────────
|
||||
#[test]
|
||||
fn test_native_backends_render_to_html() {
|
||||
// Native backends support render_to_string for testing and SSR fallback.
|
||||
let targets = [
|
||||
PlatformTarget::Ios,
|
||||
PlatformTarget::Android,
|
||||
PlatformTarget::Macos,
|
||||
PlatformTarget::Linux,
|
||||
PlatformTarget::Windows,
|
||||
];
|
||||
for target in &targets {
|
||||
let backend = backend_for(target);
|
||||
let node = PlatformNode::element("div")
|
||||
.with_child(PlatformNode::text("test"));
|
||||
let html = backend.render_to_string(&node).unwrap();
|
||||
assert!(
|
||||
html.contains("test"),
|
||||
"{} backend should render text content",
|
||||
target.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Platform configuration — parsed from `manifest.el`.
|
||||
|
||||
/// Which platform to target for rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformTarget {
|
||||
/// Web: DOM rendering in browsers.
|
||||
Web,
|
||||
/// Server: SSR — render to HTML string, served by axum.
|
||||
Server,
|
||||
/// iOS: UIKit via C FFI / ObjC bridge.
|
||||
Ios,
|
||||
/// Android: NDK + JNI bridge.
|
||||
Android,
|
||||
/// macOS: AppKit bindings.
|
||||
Macos,
|
||||
/// Linux: GTK/Wayland.
|
||||
Linux,
|
||||
/// Windows: Win32/WinUI.
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl PlatformTarget {
|
||||
/// Parse from the string value used in `manifest.el`.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"web" => Some(Self::Web),
|
||||
"server" => Some(Self::Server),
|
||||
"ios" => Some(Self::Ios),
|
||||
"android" => Some(Self::Android),
|
||||
"macos" => Some(Self::Macos),
|
||||
"linux" => Some(Self::Linux),
|
||||
"windows" => Some(Self::Windows),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical string name for this target.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Web => "web",
|
||||
Self::Server => "server",
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
Self::Macos => "macos",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this target is a native (non-web, non-server) platform.
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full platform configuration, reflecting the `[platform]` section of `manifest.el`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformConfig {
|
||||
pub target: PlatformTarget,
|
||||
/// Enable server-side rendering fallback.
|
||||
pub ssr: bool,
|
||||
}
|
||||
|
||||
impl Default for PlatformConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: PlatformTarget::Web,
|
||||
ssr: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformConfig {
|
||||
pub fn new(target: PlatformTarget) -> Self {
|
||||
Self { target, ssr: false }
|
||||
}
|
||||
|
||||
pub fn with_ssr(mut self, ssr: bool) -> Self {
|
||||
self.ssr = ssr;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -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 `manifest.el`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [platform]
|
||||
//! target = "web" # web | server | ios | android | macos | linux | windows
|
||||
//! ssr = true
|
||||
//! ```
|
||||
//!
|
||||
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
|
||||
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
|
||||
//! is correct and complete now.
|
||||
|
||||
pub mod backends;
|
||||
pub mod config;
|
||||
pub mod node;
|
||||
|
||||
pub use backends::{
|
||||
android::AndroidBackend,
|
||||
ios::IosBackend,
|
||||
linux::LinuxBackend,
|
||||
macos::MacosBackend,
|
||||
server::ServerBackend,
|
||||
web::WebBackend,
|
||||
windows::WindowsBackend,
|
||||
};
|
||||
pub use config::{PlatformConfig, PlatformTarget};
|
||||
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("render error: {0}")]
|
||||
Render(String),
|
||||
#[error("mount error: {0}")]
|
||||
Mount(String),
|
||||
#[error("unsupported operation on target {target}: {op}")]
|
||||
Unsupported { target: String, op: String },
|
||||
#[error("event binding error: {0}")]
|
||||
EventBinding(String),
|
||||
}
|
||||
|
||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||
|
||||
/// The core trait every platform backend must implement.
|
||||
///
|
||||
/// All rendering paths go through this interface. Component code is identical
|
||||
/// across targets — only the backend chosen by `manifest.el` differs.
|
||||
pub trait PlatformBackend: Send + Sync {
|
||||
/// The platform name (e.g. "web", "server", "ios").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Create a new element node on this platform.
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Create a text node on this platform.
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Set an attribute on a node.
|
||||
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove an attribute from a node.
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Append a child node to a parent.
|
||||
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove a child node from a parent.
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
|
||||
|
||||
/// Replace a child node at the given index.
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Bind an event handler to a node.
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
handler: EventHandler,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Render a node tree to its platform representation.
|
||||
/// For `server`, this returns an HTML string.
|
||||
/// For `web`, this patches the live DOM.
|
||||
/// For native targets, this calls the appropriate native APIs.
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
|
||||
|
||||
/// Mount a node tree into the platform's root container.
|
||||
/// `container_id` is a platform-specific identifier (CSS selector for web,
|
||||
/// view controller ID for iOS, activity ID for Android, etc.).
|
||||
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Patch an existing mounted tree with a new tree.
|
||||
/// The backend performs the minimal update needed.
|
||||
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
|
||||
|
||||
/// Whether this backend supports SSR (rendering to HTML string on the server).
|
||||
fn supports_ssr(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the backend for a given platform target.
|
||||
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
|
||||
match target {
|
||||
PlatformTarget::Web => Box::new(WebBackend::new()),
|
||||
PlatformTarget::Server => Box::new(ServerBackend::new()),
|
||||
PlatformTarget::Ios => Box::new(IosBackend::new()),
|
||||
PlatformTarget::Android => Box::new(AndroidBackend::new()),
|
||||
PlatformTarget::Macos => Box::new(MacosBackend::new()),
|
||||
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
|
||||
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn html_escape_attr(s: &str) -> String {
|
||||
html_escape(s).replace('"', """)
|
||||
}
|
||||
|
||||
fn is_void_element(tag: &str) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
|
||||
| "link" | "meta" | "param" | "source" | "track" | "wbr"
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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 manifest.el".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
apple_config,
|
||||
publish_config,
|
||||
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a build to TestFlight.
|
||||
///
|
||||
/// In production, this calls the App Store Connect API:
|
||||
/// POST /v1/builds
|
||||
/// PUT /v1/builds/{id}/betaAppReviewDetail
|
||||
/// POST /v1/betaTestersConfigurations
|
||||
pub fn upload_to_testflight(
|
||||
&self,
|
||||
ipa_path: &str,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: use reqwest to call App Store Connect API:
|
||||
// 1. Authenticate with JWT from API key
|
||||
// 2. POST /v1/builds with IPA binary
|
||||
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
|
||||
// 4. Submit to TestFlight review
|
||||
let _ = ipa_path;
|
||||
let submission_id = format!(
|
||||
"TF-{}-{}",
|
||||
self.apple_config.bundle_id,
|
||||
self.publish_config.build_number
|
||||
);
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
/// Submit to App Store review.
|
||||
///
|
||||
/// In production:
|
||||
/// POST /v1/appStoreVersionSubmissions
|
||||
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
|
||||
pub fn submit_to_app_store(
|
||||
&self,
|
||||
build_id: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: actual API calls
|
||||
let _ = (build_id, metadata);
|
||||
let review_id = format!("AS-{}", self.publish_config.build_number);
|
||||
Ok(review_id)
|
||||
}
|
||||
|
||||
/// Set staged rollout percentage on App Store.
|
||||
/// Only available after the initial release.
|
||||
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
|
||||
let _ = percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: build → upload → submit.
|
||||
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let build_id = self.upload_to_testflight(ipa_path)?;
|
||||
|
||||
let track = if to_beta { "testflight" } else { "app_store" };
|
||||
|
||||
if !to_beta {
|
||||
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
|
||||
let _ = review_id;
|
||||
}
|
||||
|
||||
if let Some(rollout) = &self.publish_config.rollout {
|
||||
self.set_phased_release(rollout.initial_percent)?;
|
||||
}
|
||||
|
||||
Ok(PublishOutcome::new("apple", &self.publish_config, track)
|
||||
.with_submission_id(build_id))
|
||||
}
|
||||
|
||||
/// List existing builds from App Store Connect.
|
||||
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
|
||||
// TODO: GET /v1/builds?filter[bundleId]=...
|
||||
Ok(vec![BuildInfo {
|
||||
id: format!("build-{}", self.publish_config.build_number),
|
||||
version: self.publish_config.version.clone(),
|
||||
status: "READY_FOR_DISTRIBUTION".into(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
/// Info about a build in App Store Connect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
|
||||
|
||||
/// App Store Connect / TestFlight configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleConfig {
|
||||
pub account: String,
|
||||
pub team_id: String,
|
||||
pub bundle_id: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl AppleConfig {
|
||||
pub fn new(
|
||||
account: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
bundle_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
account: account.into(),
|
||||
team_id: team_id.into(),
|
||||
bundle_id: bundle_id.into(),
|
||||
category: "productivity".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: impl Into<String>) -> Self {
|
||||
self.category = category.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Play Developer API configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoogleConfig {
|
||||
pub service_account_path: String,
|
||||
pub package_name: String,
|
||||
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
|
||||
pub track: String,
|
||||
}
|
||||
|
||||
impl GoogleConfig {
|
||||
pub fn new(
|
||||
service_account_path: impl Into<String>,
|
||||
package_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
service_account_path: service_account_path.into(),
|
||||
package_name: package_name.into(),
|
||||
track: "internal".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_track(mut self, track: impl Into<String>) -> Self {
|
||||
self.track = track.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged rollout configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutConfig {
|
||||
/// Initial rollout percentage (0-100).
|
||||
pub initial_percent: u8,
|
||||
/// Automatically advance rollout after `advance_after_hours` hours.
|
||||
pub auto_advance: bool,
|
||||
pub advance_after_hours: u64,
|
||||
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
|
||||
pub max_crash_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for RolloutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_percent: 100,
|
||||
auto_advance: false,
|
||||
advance_after_hours: 24,
|
||||
max_crash_rate: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutConfig {
|
||||
pub fn new(initial_percent: u8) -> Self {
|
||||
Self { initial_percent, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn with_auto_advance(mut self, hours: u64) -> Self {
|
||||
self.auto_advance = true;
|
||||
self.advance_after_hours = hours;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
|
||||
self.max_crash_rate = rate;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete publish configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishConfig {
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub apple: Option<AppleConfig>,
|
||||
pub google: Option<GoogleConfig>,
|
||||
pub rollout: Option<RolloutConfig>,
|
||||
/// Directory with store metadata (title.txt, description.txt, etc.)
|
||||
pub metadata_dir: String,
|
||||
pub screenshot_targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl PublishConfig {
|
||||
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
|
||||
Self {
|
||||
version: version.into(),
|
||||
build_number,
|
||||
apple: None,
|
||||
google: None,
|
||||
rollout: None,
|
||||
metadata_dir: "./store".into(),
|
||||
screenshot_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
|
||||
self.apple = Some(apple);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_google(mut self, google: GoogleConfig) -> Self {
|
||||
self.google = Some(google);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
|
||||
self.rollout = Some(rollout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
|
||||
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -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 manifest.el".into())
|
||||
})?;
|
||||
Ok(Self { google_config, publish_config })
|
||||
}
|
||||
|
||||
/// Create a new edit session on Google Play.
|
||||
///
|
||||
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
|
||||
pub fn create_edit(&self) -> PublishResult<String> {
|
||||
// TODO: OAuth2 authentication via service account JSON
|
||||
let edit_id = format!("edit-{}", self.publish_config.build_number);
|
||||
Ok(edit_id)
|
||||
}
|
||||
|
||||
/// Upload an AAB (Android App Bundle) to an edit session.
|
||||
///
|
||||
/// In production: POST .../edits/{editId}/bundles (multipart upload)
|
||||
pub fn upload_bundle(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
aab_path: &str,
|
||||
) -> PublishResult<u32> {
|
||||
let _ = (edit_id, aab_path);
|
||||
// Returns the version code of the uploaded bundle
|
||||
Ok(self.publish_config.build_number)
|
||||
}
|
||||
|
||||
/// Assign a bundle to a track.
|
||||
///
|
||||
/// In production: PUT .../edits/{editId}/tracks/{track}
|
||||
pub fn assign_to_track(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
version_code: u32,
|
||||
track: &str,
|
||||
rollout_fraction: f64,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, version_code, track, rollout_fraction);
|
||||
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
|
||||
return Err(PublishError::Config(format!(
|
||||
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
|
||||
track
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload store listing (metadata) for a locale.
|
||||
///
|
||||
/// In production: PATCH .../edits/{editId}/listings/{language}
|
||||
pub fn upload_listing(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
locale: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, locale, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit an edit (makes the changes live).
|
||||
///
|
||||
/// In production: POST .../edits/{editId}:commit
|
||||
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
|
||||
// Returns the resulting version code
|
||||
Ok(format!("{}-committed", edit_id))
|
||||
}
|
||||
|
||||
/// Update rollout percentage for a track (for staged rollouts).
|
||||
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
let _ = (track, percent);
|
||||
// TODO: PATCH .../tracks/{track} with rollout fraction
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: create edit → upload bundle → assign to track → commit.
|
||||
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let edit_id = self.create_edit()?;
|
||||
let version_code = self.upload_bundle(&edit_id, aab_path)?;
|
||||
|
||||
let track = &self.google_config.track;
|
||||
let rollout_fraction = self
|
||||
.publish_config
|
||||
.rollout
|
||||
.as_ref()
|
||||
.map(|r| r.initial_percent as f64 / 100.0)
|
||||
.unwrap_or(1.0);
|
||||
|
||||
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
|
||||
self.upload_listing(&edit_id, "en-US", &metadata)?;
|
||||
let commit_id = self.commit_edit(&edit_id)?;
|
||||
|
||||
Ok(PublishOutcome::new("google", &self.publish_config, track)
|
||||
.with_submission_id(commit_id))
|
||||
}
|
||||
}
|
||||
@@ -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 `manifest.el`:
|
||||
//! ```toml
|
||||
//! [publish]
|
||||
//! version = "1.0.0"
|
||||
//! build_number = 42
|
||||
//!
|
||||
//! [publish.apple]
|
||||
//! account = "will@neurontechnologies.ai"
|
||||
//! bundle_id = "ai.neurontechnologies.myapp"
|
||||
//!
|
||||
//! [publish.google]
|
||||
//! package = "ai.neurontechnologies.myapp"
|
||||
//! track = "internal"
|
||||
//! ```
|
||||
|
||||
pub mod apple;
|
||||
pub mod cert;
|
||||
pub mod config;
|
||||
pub mod google;
|
||||
pub mod metadata;
|
||||
pub mod rollout;
|
||||
pub mod screenshot;
|
||||
|
||||
pub use apple::ApplePublisher;
|
||||
pub use cert::{CertInfo, CertStore};
|
||||
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
|
||||
pub use google::GooglePublisher;
|
||||
pub use metadata::StoreMetadata;
|
||||
pub use rollout::RolloutMonitor;
|
||||
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishError {
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
#[error("build error: {0}")]
|
||||
Build(String),
|
||||
#[error("upload error: {0}")]
|
||||
Upload(String),
|
||||
#[error("certificate error: {0}")]
|
||||
Certificate(String),
|
||||
#[error("metadata error: {0}")]
|
||||
Metadata(String),
|
||||
#[error("api error: {status} {body}")]
|
||||
Api { status: u16, body: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub type PublishResult<T> = Result<T, PublishError>;
|
||||
|
||||
/// The outcome of a publish operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishOutcome {
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub track: String,
|
||||
pub rollout_percent: u8,
|
||||
pub submission_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PublishOutcome {
|
||||
pub fn new(
|
||||
platform: impl Into<String>,
|
||||
config: &PublishConfig,
|
||||
track: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: platform.into(),
|
||||
version: config.version.clone(),
|
||||
build_number: config.build_number,
|
||||
track: track.into(),
|
||||
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
|
||||
submission_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.submission_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 manifest.el (e.g. "iphone-6.7")
|
||||
pub id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub platform: ScreenshotPlatform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScreenshotPlatform {
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
impl ScreenshotTarget {
|
||||
/// All standard screenshot targets, matching Apple and Google requirements.
|
||||
pub fn all_standard() -> Vec<Self> {
|
||||
vec![
|
||||
Self {
|
||||
name: "iPhone 6.7\"".into(),
|
||||
id: "iphone-6.7".into(),
|
||||
width: 1290,
|
||||
height: 2796,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPhone 6.1\"".into(),
|
||||
id: "iphone-6.1".into(),
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPad 13\"".into(),
|
||||
id: "ipad-13".into(),
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "Android Phone".into(),
|
||||
id: "android-phone".into(),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
platform: ScreenshotPlatform::Android,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Find a target by its ID.
|
||||
pub fn find(id: &str) -> Option<Self> {
|
||||
Self::all_standard().into_iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured screenshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Screenshot {
|
||||
pub target: ScreenshotTarget,
|
||||
pub locale: String,
|
||||
pub scenario: String,
|
||||
/// Path to the PNG file.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Trait for screenshot capture backends.
|
||||
///
|
||||
/// Implementations might use:
|
||||
/// - `xcrun simctl` for iOS Simulator screenshots
|
||||
/// - Android Emulator ADB screenshots
|
||||
/// - Puppeteer/Playwright for web screenshots
|
||||
pub trait ScreenshotCapture: Send + Sync {
|
||||
/// Capture a screenshot for the given scenario and target.
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot>;
|
||||
|
||||
/// Capture all scenarios for all targets and locales.
|
||||
fn capture_all(
|
||||
&self,
|
||||
scenarios: &[String],
|
||||
targets: &[ScreenshotTarget],
|
||||
locales: &[String],
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Vec<Screenshot>> {
|
||||
let mut screenshots = Vec::new();
|
||||
for scenario in scenarios {
|
||||
for target in targets {
|
||||
for locale in locales {
|
||||
let shot = self.capture(scenario, target, locale, output_dir)?;
|
||||
screenshots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(screenshots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub screenshot capture (produces placeholder paths without actually
|
||||
/// launching a simulator). A future agent fills in the real capture logic.
|
||||
pub struct StubScreenshotCapture;
|
||||
|
||||
impl ScreenshotCapture for StubScreenshotCapture {
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot> {
|
||||
let filename = format!(
|
||||
"{}/{}/{}-{}.png",
|
||||
output_dir, locale, target.id, scenario
|
||||
);
|
||||
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
|
||||
Ok(Screenshot {
|
||||
target: target.clone(),
|
||||
locale: locale.to_string(),
|
||||
scenario: scenario.to_string(),
|
||||
path: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "el-secrets"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui secrets management — typed, never-logged, source-agnostic"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_secrets"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,20 @@
|
||||
// el-secrets — Secrets management for el-ui applications.
|
||||
//
|
||||
// Secrets are NEVER in code. Wrapped in a `Secret<T>` type that displays as
|
||||
// `[REDACTED]` in all logs and JSON output. Explicit `.expose()` to read.
|
||||
|
||||
vessel "el-secrets" {
|
||||
version "0.1.0"
|
||||
description "Resolver chain: env vars, Vault, AWS Secrets Manager"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
#[error("secret '{key}' not found in source '{source_name}'")]
|
||||
NotFound { key: String, source_name: String },
|
||||
|
||||
#[error("secret '{key}' not found in any configured source")]
|
||||
NotFoundInAnySource { key: String },
|
||||
|
||||
#[error("secrets source '{source_name}' unavailable: {reason}")]
|
||||
SourceUnavailable { source_name: String, reason: String },
|
||||
|
||||
#[error("required secrets missing at startup: {keys:?}")]
|
||||
MissingRequired { keys: Vec<String> },
|
||||
|
||||
#[error("secret type conversion failed for key '{key}': {reason}")]
|
||||
TypeError { key: String, reason: String },
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! el-secrets — Secrets management for el-ui applications.
|
||||
//!
|
||||
//! Secrets are NEVER in code. They are:
|
||||
//! - `EnvVarSource` — environment variables (`EL_SECRET_JWT_KEY=...`)
|
||||
//! - `VaultSource` — HashiCorp Vault (stub, ready for HTTP client integration)
|
||||
//! - `AwsSecretsSource` — AWS Secrets Manager (stub)
|
||||
//! - `InMemorySource` — for testing only
|
||||
//!
|
||||
//! All secrets are wrapped in `Secret<T>` which:
|
||||
//! - Displays as `[REDACTED]` in all logs and debug output
|
||||
//! - Serializes as `"[REDACTED]"` in JSON — never the actual value
|
||||
//! - Requires explicit `.expose()` to read
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use el_secrets::prelude::*;
|
||||
//!
|
||||
//! // Load secrets at startup — fails early if anything is missing
|
||||
//! let mut src = InMemorySource::new();
|
||||
//! src.insert("jwt.key", "test-key-for-testing-only");
|
||||
//!
|
||||
//! let secrets = SecretsResolver::new()
|
||||
//! .source(Box::new(src))
|
||||
//! .require("jwt.key")
|
||||
//! .resolve()
|
||||
//! .expect("required secrets must be present at startup");
|
||||
//!
|
||||
//! let jwt_key = secrets.require("jwt.key");
|
||||
//! // jwt_key displays as [REDACTED]
|
||||
//! // jwt_key.expose() gives the actual value
|
||||
//! assert_eq!(jwt_key.expose(), "test-key-for-testing-only");
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod error;
|
||||
pub mod resolver;
|
||||
pub mod secret;
|
||||
pub mod source;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::error::SecretsError;
|
||||
pub use crate::resolver::{ResolvedSecrets, SecretsResolver};
|
||||
pub use crate::secret::{Secret, SecretGuard};
|
||||
pub use crate::source::{
|
||||
AwsSecretsSource, EnvVarSource, InMemorySource, SecretsSource, VaultSource,
|
||||
};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,195 @@
|
||||
// el-secrets — Secrets management for el-ui.
|
||||
//
|
||||
// A Secret is a value with a redacted display contract. Source plugins
|
||||
// fetch by key from env, Vault, AWS Secrets Manager, or in-memory.
|
||||
// `SecretsResolver` composes a chain and resolves required keys at startup.
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let SECRET_ERR_MISSING: String = "secret.missing"
|
||||
let SECRET_ERR_SOURCE: String = "secret.source_error"
|
||||
let SECRET_ERR_PERMISSION: String = "secret.permission_denied"
|
||||
|
||||
// ── Source kinds ─────────────────────────────────────────────────────────────
|
||||
|
||||
let SRC_ENV: String = "env"
|
||||
let SRC_VAULT: String = "vault"
|
||||
let SRC_AWS_SECRETS: String = "aws_secrets"
|
||||
let SRC_IN_MEMORY: String = "in_memory"
|
||||
|
||||
// ── Source descriptor ───────────────────────────────────────────────────────
|
||||
|
||||
type SecretsSource {
|
||||
kind: String // env | vault | aws_secrets | in_memory
|
||||
config_json: String // source-specific (e.g. {"prefix":"EL_SECRET_"} or {"path":"secret/data/app"})
|
||||
}
|
||||
|
||||
fn source_env(prefix: String) -> SecretsSource {
|
||||
{ "kind": "env", "config_json": "{\"prefix\":\"" + prefix + "\"}" }
|
||||
}
|
||||
|
||||
fn source_vault(addr: String, token: String, mount: String) -> SecretsSource {
|
||||
let cfg: String = "{\"addr\":\"" + addr + "\",\"token\":\"" + token
|
||||
+ "\",\"mount\":\"" + mount + "\"}"
|
||||
{ "kind": "vault", "config_json": cfg }
|
||||
}
|
||||
|
||||
fn source_aws(region: String) -> SecretsSource {
|
||||
{ "kind": "aws_secrets", "config_json": "{\"region\":\"" + region + "\"}" }
|
||||
}
|
||||
|
||||
fn source_in_memory() -> SecretsSource {
|
||||
{ "kind": "in_memory", "config_json": "{\"map\":{}}" }
|
||||
}
|
||||
|
||||
// ── Source lookup ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each source kind has its own fetch fn. `source_get` dispatches by kind.
|
||||
|
||||
fn source_get(src: SecretsSource, key: String) -> String {
|
||||
if str_eq(src.kind, "env") {
|
||||
let prefix: String = json_get(src.config_json, "prefix")
|
||||
return env(prefix + str_to_upper(str_replace(key, ".", "_")))
|
||||
}
|
||||
if str_eq(src.kind, "vault") {
|
||||
let addr: String = json_get(src.config_json, "addr")
|
||||
let token: String = json_get(src.config_json, "token")
|
||||
let mount: String = json_get(src.config_json, "mount")
|
||||
return vault_kv_get_at(addr, token, mount, key)
|
||||
}
|
||||
if str_eq(src.kind, "aws_secrets") {
|
||||
let region: String = json_get(src.config_json, "region")
|
||||
return aws_secrets_get(region, key)
|
||||
}
|
||||
if str_eq(src.kind, "in_memory") {
|
||||
let map: String = json_get(src.config_json, "map")
|
||||
return json_get(map, key)
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn in_memory_insert(src: SecretsSource, key: String, value: String) -> SecretsSource {
|
||||
let map: String = json_get(src.config_json, "map")
|
||||
let map = json_set(map, key, "\"" + value + "\"")
|
||||
let cfg = json_set(src.config_json, "map", map)
|
||||
{ "kind": src.kind, "config_json": cfg }
|
||||
}
|
||||
|
||||
// ── Secret<T> — redacted wrapper ────────────────────────────────────────────
|
||||
//
|
||||
// A Secret stores its raw value behind a tag. `secret_display` always
|
||||
// returns `[REDACTED]`. `secret_expose` returns the actual string.
|
||||
// Serializers should call `secret_display` by default.
|
||||
|
||||
type Secret {
|
||||
tag: String // marker: "secret"
|
||||
raw: String // the actual value — never log this directly
|
||||
}
|
||||
|
||||
fn secret_new(value: String) -> Secret {
|
||||
{ "tag": "secret", "raw": value }
|
||||
}
|
||||
|
||||
fn secret_display(s: Secret) -> String {
|
||||
"[REDACTED]"
|
||||
}
|
||||
|
||||
fn secret_expose(s: Secret) -> String {
|
||||
s.raw
|
||||
}
|
||||
|
||||
// Disposable read-once guard — like Rust's `SecretGuard`. Consumes the secret
|
||||
// once and zeroes it. Useful at boundaries: read JWT signing key once, hand to
|
||||
// the signing routine, never readable again.
|
||||
type SecretGuard {
|
||||
consumed: Bool
|
||||
raw: String
|
||||
}
|
||||
|
||||
fn guard_new(s: Secret) -> SecretGuard {
|
||||
{ "consumed": false, "raw": s.raw }
|
||||
}
|
||||
|
||||
fn guard_take(g: SecretGuard) -> String {
|
||||
if g.consumed { return "" }
|
||||
g.raw
|
||||
}
|
||||
|
||||
// ── SecretsResolver — builder ───────────────────────────────────────────────
|
||||
//
|
||||
// Sources are tried in order. First non-empty hit wins.
|
||||
// `require(key)` marks a key as required at startup; `resolve()` fails if any
|
||||
// required key is unresolved.
|
||||
|
||||
type SecretsResolver {
|
||||
sources_json: String // JSON array of SecretsSource
|
||||
required_json: String // JSON array of required keys
|
||||
}
|
||||
|
||||
fn resolver_new() -> SecretsResolver {
|
||||
{ "sources_json": "[]", "required_json": "[]" }
|
||||
}
|
||||
|
||||
fn resolver_source(r: SecretsResolver, src: SecretsSource) -> SecretsResolver {
|
||||
{ "sources_json": json_array_push(r.sources_json, json_encode(src)),
|
||||
"required_json": r.required_json }
|
||||
}
|
||||
|
||||
fn resolver_require(r: SecretsResolver, key: String) -> SecretsResolver {
|
||||
{ "sources_json": r.sources_json,
|
||||
"required_json": json_array_push(r.required_json, "\"" + key + "\"") }
|
||||
}
|
||||
|
||||
// Resolved bag. Keys -> Secret. Failure if any required key is missing.
|
||||
type ResolvedSecrets {
|
||||
map_json: String // JSON: key -> raw value
|
||||
errors: String // JSON array of missing required keys
|
||||
}
|
||||
|
||||
fn resolver_resolve(r: SecretsResolver) -> ResolvedSecrets {
|
||||
let resolved: String = "{}"
|
||||
let errors: String = "[]"
|
||||
let n: Int = json_array_len(r.required_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let key: String = json_array_get(r.required_json, i)
|
||||
let value: String = lookup_chain(r.sources_json, key)
|
||||
if str_eq(value, "") {
|
||||
let errors = json_array_push(errors, "\"" + key + "\"")
|
||||
}
|
||||
if !str_eq(value, "") {
|
||||
let resolved = json_set(resolved, key, "\"" + value + "\"")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
{ "map_json": resolved, "errors": errors }
|
||||
}
|
||||
|
||||
fn lookup_chain(sources_json: String, key: String) -> String {
|
||||
let n: Int = json_array_len(sources_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let src_json: String = json_array_get(sources_json, i)
|
||||
let src: SecretsSource = { "kind": json_get(src_json, "kind"),
|
||||
"config_json": json_get(src_json, "config_json") }
|
||||
let v: String = source_get(src, key)
|
||||
if !str_eq(v, "") { return v }
|
||||
let i = i + 1
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn resolved_require(r: ResolvedSecrets, key: String) -> Secret {
|
||||
let raw: String = json_get(r.map_json, key)
|
||||
secret_new(raw)
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let src: SecretsSource = source_in_memory()
|
||||
let src = in_memory_insert(src, "jwt.key", "test-key-for-testing-only")
|
||||
let resolver: SecretsResolver = resolver_source(resolver_new(), src)
|
||||
let resolver = resolver_require(resolver, "jwt.key")
|
||||
let resolved: ResolvedSecrets = resolver_resolve(resolver)
|
||||
let jwt: Secret = resolved_require(resolved, "jwt.key")
|
||||
println("[el-secrets] jwt.key = " + secret_display(jwt))
|
||||
@@ -0,0 +1,282 @@
|
||||
/// SecretsResolver — loads all required secrets at startup.
|
||||
///
|
||||
/// The resolver validates that all required secrets are present before the
|
||||
/// application starts. If any are missing, startup fails with a clear error
|
||||
/// listing what's missing — not a runtime panic deep in the app.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::SecretsError;
|
||||
use crate::secret::Secret;
|
||||
use crate::source::SecretsSource;
|
||||
|
||||
/// A resolved, validated set of secrets.
|
||||
///
|
||||
/// Created by SecretsResolver at startup. Once resolved, all secrets are
|
||||
/// available and guaranteed to have been present at startup time.
|
||||
pub struct ResolvedSecrets {
|
||||
values: HashMap<String, Secret<String>>,
|
||||
}
|
||||
|
||||
impl ResolvedSecrets {
|
||||
/// Get a secret by key.
|
||||
///
|
||||
/// Returns None if the key wasn't declared as required.
|
||||
/// In normal use you will always have the keys you declared.
|
||||
pub fn get(&self, key: &str) -> Option<&Secret<String>> {
|
||||
self.values.get(key)
|
||||
}
|
||||
|
||||
/// Get a secret or panic with a clear message.
|
||||
///
|
||||
/// Use this for secrets that are truly required and were declared in
|
||||
/// the resolver — if the resolver passed, this will always succeed.
|
||||
pub fn require(&self, key: &str) -> &Secret<String> {
|
||||
self.values.get(key).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Secret '{}' was not declared as required in SecretsResolver. \
|
||||
Declare all required secrets before calling resolve().",
|
||||
key
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The number of resolved secrets.
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and validates all required secrets at startup.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```ignore
|
||||
/// let secrets = SecretsResolver::new()
|
||||
/// .source(EnvVarSource::new())
|
||||
/// .require("jwt.secret_key")
|
||||
/// .require("database.password")
|
||||
/// .resolve()?;
|
||||
///
|
||||
/// let jwt_key = secrets.require("jwt.secret_key");
|
||||
/// ```
|
||||
pub struct SecretsResolver {
|
||||
sources: Vec<Box<dyn SecretsSource>>,
|
||||
required: Vec<String>,
|
||||
}
|
||||
|
||||
impl SecretsResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sources: Vec::new(),
|
||||
required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a secret source. Sources are tried in order; first success wins.
|
||||
pub fn source(mut self, source: Box<dyn SecretsSource>) -> Self {
|
||||
self.sources.push(source);
|
||||
self
|
||||
}
|
||||
|
||||
/// Declare a required secret key.
|
||||
///
|
||||
/// All required keys must be present in at least one source.
|
||||
/// resolve() fails if any are missing.
|
||||
pub fn require(mut self, key: impl Into<String>) -> Self {
|
||||
self.required.push(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Declare multiple required secret keys.
|
||||
pub fn require_all(mut self, keys: &[&str]) -> Self {
|
||||
for key in keys {
|
||||
self.required.push(key.to_string());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Load all required secrets and validate they are all present.
|
||||
///
|
||||
/// Returns an error listing ALL missing secrets — not just the first one —
|
||||
/// so you can fix them all in one go.
|
||||
pub fn resolve(self) -> Result<ResolvedSecrets, SecretsError> {
|
||||
let mut values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
|
||||
for key in &self.required {
|
||||
match self.fetch_from_sources(key) {
|
||||
Ok(secret) => {
|
||||
values.insert(key.clone(), secret);
|
||||
}
|
||||
Err(_) => {
|
||||
missing.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
return Err(SecretsError::MissingRequired { keys: missing });
|
||||
}
|
||||
|
||||
Ok(ResolvedSecrets { values })
|
||||
}
|
||||
|
||||
/// Attempt to fetch a key from sources in order.
|
||||
fn fetch_from_sources(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
for source in &self.sources {
|
||||
if let Ok(secret) = source.get(key) {
|
||||
return Ok(secret);
|
||||
}
|
||||
}
|
||||
Err(SecretsError::NotFoundInAnySource {
|
||||
key: key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve without requiring all keys — useful when you want
|
||||
/// to load whatever is available.
|
||||
pub fn resolve_optional(self) -> ResolvedSecrets {
|
||||
let mut values = HashMap::new();
|
||||
for key in &self.required {
|
||||
if let Ok(secret) = self.fetch_from_sources(key) {
|
||||
values.insert(key.clone(), secret);
|
||||
}
|
||||
}
|
||||
ResolvedSecrets { values }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecretsResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::InMemorySource;
|
||||
|
||||
fn make_source(pairs: &[(&str, &str)]) -> InMemorySource {
|
||||
let mut src = InMemorySource::new();
|
||||
for (k, v) in pairs {
|
||||
src.insert(*k, *v);
|
||||
}
|
||||
src
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_present() {
|
||||
let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("db.password")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("jwt.key").expose(), "secret");
|
||||
assert_eq!(secrets.require("db.password").expose(), "pass123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_fails_with_list() {
|
||||
let src = make_source(&[("jwt.key", "secret")]);
|
||||
let result = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("db.password") // missing
|
||||
.require("stripe.key") // also missing
|
||||
.resolve();
|
||||
|
||||
match result {
|
||||
Err(SecretsError::MissingRequired { keys }) => {
|
||||
assert!(keys.contains(&"db.password".to_string()));
|
||||
assert!(keys.contains(&"stripe.key".to_string()));
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
||||
_ => panic!("expected MissingRequired error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_optional_skips_missing() {
|
||||
let src = make_source(&[("jwt.key", "secret")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("missing.key")
|
||||
.resolve_optional();
|
||||
|
||||
assert!(secrets.get("jwt.key").is_some());
|
||||
assert!(secrets.get("missing.key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_sources_fallback() {
|
||||
let mut primary = InMemorySource::new();
|
||||
primary.insert("jwt.key", "from-primary");
|
||||
let mut secondary = InMemorySource::new();
|
||||
secondary.insert("db.password", "from-secondary");
|
||||
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(primary))
|
||||
.source(Box::new(secondary))
|
||||
.require("jwt.key")
|
||||
.require("db.password")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("jwt.key").expose(), "from-primary");
|
||||
assert_eq!(secrets.require("db.password").expose(), "from-secondary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_source_wins() {
|
||||
let mut s1 = InMemorySource::new();
|
||||
s1.insert("key", "value-1");
|
||||
let mut s2 = InMemorySource::new();
|
||||
s2.insert("key", "value-2");
|
||||
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(s1))
|
||||
.source(Box::new(s2))
|
||||
.require("key")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("key").expose(), "value-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_all() {
|
||||
let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require_all(&["a", "b", "c"])
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_len() {
|
||||
let src = make_source(&[("k", "v")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("k")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert_eq!(secrets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_is_empty() {
|
||||
let resolved = ResolvedSecrets { values: HashMap::new() };
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/// Secret<T> — a typed value that never leaks via Display/Debug.
|
||||
///
|
||||
/// The wrapper ensures that accidental logging or serialization of a secret
|
||||
/// never reveals the actual value. You must explicitly call `.expose()` to
|
||||
/// read it, which creates a visible opt-in point in the code.
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
/// A secret value. Never prints the inner value via Display or Debug.
|
||||
///
|
||||
/// Always displays as `[REDACTED]`. To access the inner value:
|
||||
/// ```
|
||||
/// use el_secrets::Secret;
|
||||
/// let key = Secret::new("my-secret-key".to_string());
|
||||
/// let actual: &str = key.expose(); // explicit opt-in
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Secret<T: Clone>(T);
|
||||
|
||||
impl<T: Clone> Secret<T> {
|
||||
/// Wrap a value in a Secret.
|
||||
pub fn new(value: T) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
/// Access the inner value.
|
||||
///
|
||||
/// This is the ONLY way to get the actual secret value out.
|
||||
/// Name it `expose` so it's searchable in code review.
|
||||
pub fn expose(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Consume the Secret and return the inner value.
|
||||
pub fn into_inner(self) -> T {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Map the inner value to a new type, wrapping in a new Secret.
|
||||
pub fn map<U: Clone, F: FnOnce(T) -> U>(self, f: F) -> Secret<U> {
|
||||
Secret(f(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug never reveals the secret value.
|
||||
impl<T: Clone> std::fmt::Debug for Secret<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Secret([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Display never reveals the secret value.
|
||||
impl<T: Clone> std::fmt::Display for Secret<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize writes [REDACTED], never the actual value.
|
||||
/// This prevents secrets from appearing in JSON logs, API responses, etc.
|
||||
impl<T: Clone> Serialize for Secret<T> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str("[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize from a string — used when loading secrets from files/env.
|
||||
/// Only implemented for Secret<String> since we always load as strings.
|
||||
impl<'de> Deserialize<'de> for Secret<String> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(Secret::new(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard that prevents a value from being accidentally exposed.
|
||||
///
|
||||
/// Use this on struct fields that should never be serialized or logged.
|
||||
#[derive(Clone)]
|
||||
pub struct SecretGuard<T: Clone> {
|
||||
inner: Secret<T>,
|
||||
/// A hint shown in Debug output (not the value itself).
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Clone> SecretGuard<T> {
|
||||
pub fn new(value: T, label: &'static str) -> Self {
|
||||
Self {
|
||||
inner: Secret::new(value),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &T {
|
||||
self.inner.expose()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> std::fmt::Debug for SecretGuard<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SecretGuard({}: [REDACTED])", self.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn secret_expose() {
|
||||
let s = Secret::new("my-api-key".to_string());
|
||||
assert_eq!(s.expose(), "my-api-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_debug_redacted() {
|
||||
let s = Secret::new("super-secret".to_string());
|
||||
let debug = format!("{:?}", s);
|
||||
assert_eq!(debug, "Secret([REDACTED])");
|
||||
assert!(!debug.contains("super-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_display_redacted() {
|
||||
let s = Secret::new(12345u32);
|
||||
let display = format!("{}", s);
|
||||
assert_eq!(display, "[REDACTED]");
|
||||
assert!(!display.contains("12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_serialize_redacted() {
|
||||
let s = Secret::new("should-not-appear".to_string());
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(json, r#""[REDACTED]""#);
|
||||
assert!(!json.contains("should-not-appear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_deserialize() {
|
||||
let s: Secret<String> = serde_json::from_str(r#""my-secret""#).unwrap();
|
||||
assert_eq!(s.expose(), "my-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_map() {
|
||||
let s = Secret::new("42".to_string());
|
||||
let n: Secret<u32> = s.map(|v| v.parse().unwrap());
|
||||
assert_eq!(*n.expose(), 42u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_into_inner() {
|
||||
let s = Secret::new("value".to_string());
|
||||
assert_eq!(s.into_inner(), "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_guard_debug() {
|
||||
let g = SecretGuard::new("token".to_string(), "jwt_token");
|
||||
let debug = format!("{:?}", g);
|
||||
assert!(debug.contains("jwt_token"));
|
||||
assert!(!debug.contains("token\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_guard_expose() {
|
||||
let g = SecretGuard::new("secret-value".to_string(), "api_key");
|
||||
assert_eq!(g.expose(), "secret-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_clone_does_not_expose() {
|
||||
let s1 = Secret::new("clone-me".to_string());
|
||||
let s2 = s1.clone();
|
||||
assert_eq!(s2.expose(), "clone-me");
|
||||
assert!(!format!("{:?}", s2).contains("clone-me"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/// SecretsSource trait and built-in implementations.
|
||||
///
|
||||
/// Each source knows how to retrieve a named secret. Sources are tried in
|
||||
/// order by the SecretsResolver until one succeeds.
|
||||
|
||||
use crate::error::SecretsError;
|
||||
use crate::secret::Secret;
|
||||
|
||||
/// A source of secret values.
|
||||
pub trait SecretsSource: Send + Sync {
|
||||
/// The name of this source.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Retrieve a secret by key.
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError>;
|
||||
|
||||
/// List available secret keys (may not be supported by all sources).
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError>;
|
||||
}
|
||||
|
||||
/// Reads secrets from environment variables.
|
||||
///
|
||||
/// Key mapping: `jwt.key` → `EL_SECRET_JWT_KEY` (uppercased, dots → underscores).
|
||||
pub struct EnvVarSource {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl EnvVarSource {
|
||||
pub fn new() -> Self {
|
||||
Self { prefix: "EL_SECRET".to_string() }
|
||||
}
|
||||
|
||||
pub fn with_prefix(prefix: impl Into<String>) -> Self {
|
||||
Self { prefix: prefix.into() }
|
||||
}
|
||||
|
||||
fn env_key(&self, key: &str) -> String {
|
||||
let normalized = key.replace('.', "_").replace('-', "_").to_uppercase();
|
||||
format!("{}_{}", self.prefix, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EnvVarSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for EnvVarSource {
|
||||
fn name(&self) -> &str {
|
||||
"env-var"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
let env_key = self.env_key(key);
|
||||
std::env::var(&env_key)
|
||||
.map(Secret::new)
|
||||
.map_err(|_| SecretsError::NotFound {
|
||||
key: key.to_string(),
|
||||
source_name: "env-var".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
let prefix = format!("{}_", self.prefix);
|
||||
let keys: Vec<String> = std::env::vars()
|
||||
.filter(|(k, _)| k.starts_with(&prefix))
|
||||
.map(|(k, _)| {
|
||||
k.strip_prefix(&prefix)
|
||||
.unwrap_or(&k)
|
||||
.to_lowercase()
|
||||
.replace('_', ".")
|
||||
})
|
||||
.collect();
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory secrets source for testing.
|
||||
///
|
||||
/// NEVER use in production — secrets are in plaintext in memory
|
||||
/// and this source is not safe for production credentials.
|
||||
#[derive(Default)]
|
||||
pub struct InMemorySource {
|
||||
secrets: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl InMemorySource {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Insert a secret. Only for testing.
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.secrets.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for InMemorySource {
|
||||
fn name(&self) -> &str {
|
||||
"in-memory"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
self.secrets
|
||||
.get(key)
|
||||
.map(|v| Secret::new(v.clone()))
|
||||
.ok_or_else(|| SecretsError::NotFound {
|
||||
key: key.to_string(),
|
||||
source_name: "in-memory".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Ok(self.secrets.keys().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Vault source stub — HashiCorp Vault integration.
|
||||
///
|
||||
/// This is a stub that defines the interface. A real implementation
|
||||
/// would make HTTP calls to the Vault API. The stub is sufficient
|
||||
/// for the type system and resolver to work correctly.
|
||||
pub struct VaultSource {
|
||||
/// Vault server URL (e.g. "https://vault.example.com").
|
||||
pub address: String,
|
||||
/// Mount path for the KV engine (e.g. "secret").
|
||||
pub mount: String,
|
||||
/// The Vault path prefix for this app's secrets.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl VaultSource {
|
||||
pub fn new(address: impl Into<String>, mount: impl Into<String>, path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
address: address.into(),
|
||||
mount: mount.into(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for VaultSource {
|
||||
fn name(&self) -> &str {
|
||||
"vault"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
// Stub: in a real impl, make HTTP GET to Vault KV API
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "vault".to_string(),
|
||||
reason: format!(
|
||||
"Vault HTTP client not implemented in stub — would fetch {}/{}/{}/{}",
|
||||
self.address, self.mount, self.path, key
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "vault".to_string(),
|
||||
reason: "Vault HTTP client not implemented in stub".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// AWS Secrets Manager source stub.
|
||||
pub struct AwsSecretsSource {
|
||||
pub region: String,
|
||||
pub path_prefix: String,
|
||||
}
|
||||
|
||||
impl AwsSecretsSource {
|
||||
pub fn new(region: impl Into<String>, path_prefix: impl Into<String>) -> Self {
|
||||
Self {
|
||||
region: region.into(),
|
||||
path_prefix: path_prefix.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for AwsSecretsSource {
|
||||
fn name(&self) -> &str {
|
||||
"aws-secrets-manager"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "aws-secrets-manager".to_string(),
|
||||
reason: format!(
|
||||
"AWS SDK not linked — would fetch {}/{} in {}",
|
||||
self.path_prefix, key, self.region
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "aws-secrets-manager".to_string(),
|
||||
reason: "AWS SDK not linked".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_memory_get() {
|
||||
let mut src = InMemorySource::new();
|
||||
src.insert("jwt.key", "secret-jwt-key");
|
||||
let val = src.get("jwt.key").unwrap();
|
||||
assert_eq!(val.expose(), "secret-jwt-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_missing() {
|
||||
let src = InMemorySource::new();
|
||||
assert!(src.get("missing.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_list() {
|
||||
let mut src = InMemorySource::new();
|
||||
src.insert("key.a", "a");
|
||||
src.insert("key.b", "b");
|
||||
let mut keys = src.list().unwrap();
|
||||
keys.sort();
|
||||
assert_eq!(keys, vec!["key.a", "key.b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_key_mapping() {
|
||||
let src = EnvVarSource::new();
|
||||
assert_eq!(src.env_key("jwt.key"), "EL_SECRET_JWT_KEY");
|
||||
assert_eq!(src.env_key("database.password"), "EL_SECRET_DATABASE_PASSWORD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_source_stub_returns_error() {
|
||||
let src = VaultSource::new("https://vault.example.com", "secret", "myapp");
|
||||
assert!(src.get("jwt.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_source_stub_returns_error() {
|
||||
let src = AwsSecretsSource::new("us-east-1", "myapp");
|
||||
assert!(src.get("jwt.key").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-services"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui service bindings — write once, bind to any protocol"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_services"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,20 @@
|
||||
// el-services — Service bindings for el-ui.
|
||||
//
|
||||
// Write once. Bind to any protocol. Change the binding in `manifest.el`.
|
||||
|
||||
vessel "el-services" {
|
||||
version "0.1.0"
|
||||
description "Pluggable service bindings: REST, gRPC, WebSocket, direct"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-config "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Direct binding — server-side only; calls Rust functions directly, zero network.
|
||||
//!
|
||||
//! Used when the component and the service implementation run in the same
|
||||
//! process. Eliminates all serialization/deserialization overhead.
|
||||
//!
|
||||
//! The handler registry maps `"ServiceName::method_name"` to a function pointer.
|
||||
//! The proxy calls the function directly.
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{ServiceError, ServiceResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A direct handler function — takes params, returns a JSON body string.
|
||||
pub type DirectHandler = Arc<dyn Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync>;
|
||||
|
||||
/// Direct binding — calls registered Rust functions without any network hop.
|
||||
pub struct DirectBinding {
|
||||
/// Registry: `"ServiceName::method_name"` → handler fn
|
||||
handlers: Arc<RwLock<HashMap<String, DirectHandler>>>,
|
||||
}
|
||||
|
||||
impl DirectBinding {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a handler for a service method.
|
||||
///
|
||||
/// ```
|
||||
/// # use el_services::binding::direct::DirectBinding;
|
||||
/// let binding = DirectBinding::new();
|
||||
/// binding.register("UserService", "get_user", |params| {
|
||||
/// let id = params.get("id").map(|s| s.as_str()).unwrap_or("unknown");
|
||||
/// Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
|
||||
/// });
|
||||
/// ```
|
||||
pub fn register(
|
||||
&self,
|
||||
service: &str,
|
||||
method: &str,
|
||||
handler: impl Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync + 'static,
|
||||
) {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.write()
|
||||
.expect("lock poisoned")
|
||||
.insert(key, Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Check if a handler is registered.
|
||||
pub fn has_handler(&self, service: &str, method: &str) -> bool {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.read()
|
||||
.expect("lock poisoned")
|
||||
.contains_key(&key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DirectBinding {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Binding for DirectBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Direct
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
let key = format!("{}::{}", request.service, request.method);
|
||||
let handlers = self.handlers.read().expect("lock poisoned");
|
||||
let handler = handlers.get(&key).ok_or_else(|| ServiceError::MethodNotFound {
|
||||
service: request.service.clone(),
|
||||
method: request.method.clone(),
|
||||
})?;
|
||||
let body = handler(request.params)?;
|
||||
Ok(ServiceResponse::ok(body))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user