Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ members = [
"crates/ogar-from-ruff",
"crates/ogar-from-rails",
"crates/ogar-class-view",
"crates/ogar-render-askama",
]

[workspace.package]
Expand Down
10 changes: 10 additions & 0 deletions crates/ogar-render-askama/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "ogar-render-askama"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Build-time askama codegen harness over the canonical layer — one `ArtifactKind` enum dispatching `ogar_vocab::Class` to per-kind askama templates (Rust struct, TS interface, SurrealQL TABLE, OpenAPI schema, …). Structurally mirrors AdaWorldAPI/woa-rs `codegen` crate (RFC-v02-006: spec + HandlerKind + per-kind template); the canonical input is `Class` here instead of `RouteSpec`."

[dependencies]
ogar-vocab = { path = "../ogar-vocab" }
askama = "0.12"
78 changes: 78 additions & 0 deletions crates/ogar-render-askama/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# `ogar-render-askama`

Build-time askama codegen harness over the calcified canonical layer.
One [`ArtifactKind`] enum, one askama template per kind, one
[`ArtifactEmitter`] trait, one [`for_kind`] dispatcher. Consumes
[`ogar_vocab::Class`] as the typed input.

## Structural antecedent

This crate mirrors [`AdaWorldAPI/woa-rs`](https://github.com/AdaWorldAPI/woa-rs)
`crates/codegen` (RFC-v02-006: "route codegen for the WoA → woa-rs port").
WoA's structure:

| WoA-rs concept | OGAR analog |
|---|---|
| `RouteSpec` (JSON-loaded) | `ogar_vocab::Class` (in-process from class fn) |
| `enum HandlerKind` (13 variants) | `enum ArtifactKind` (5 today, append-only) |
| `trait HandlerKindEmitter` | `trait ArtifactEmitter` |
| `for_kind(s) -> Box<dyn …>` | identical |
| `templates/_dispatch/list_view.html` | `templates/dispatch/rust_struct.askama` |
| Phase-0: one real emitter (`list_for_tenant`) + stubs | Phase-0: one real emitter (`RustStruct`) + stubs |

The bounded-template-count proof from WoA carries over: adding a new
canonical concept (one OGAR class fn) costs **zero new templates** — it
flows through the existing kit via the codebook + class data.

## What this is NOT

- **Not** the run-time projection layer. That is
[`lance-graph-contract::class_view::ClassView`](https://github.com/AdaWorldAPI/lance-graph/blob/main/crates/lance-graph-contract/src/class_view.rs)
— `ClassId + FieldMask → Vec<RenderRow>`, late-resolved labels, presence
bits. OGAR-side `ClassView` impl lives in `ogar-class-view` (sister
crate; PR #77). Both pipelines are askama-templated; both share the N3
field order convention. They consume different shapes.
- **Not** a runtime template engine. Templates are compile-time-bound
via `#[derive(Template)]`; the binary ships with them inlined.

## Phase-0 state

- `ArtifactKind::RustStruct` — **real** emitter + template. Emits a Rust
struct + `pub const CLASS_ID: u16` constant + family-edge fields from
the canonical class.
- `ArtifactKind::TsInterface` / `SurrealqlTable` / `OpenapiSchema` /
`NodeGuidRoutingArm` — **stubs** (compilable; emit a marker comment).
Concrete templates land in follow-on PRs T2–T5 per the integration plan.

## Roadmap (informational — not in this PR)

- **T2–T5**: each remaining `ArtifactKind` gets a concrete askama
template + emitter. Same `(class, kind)` context shape across all.
- **T6 — A2UI payload** (deferred):
[`AdaWorldAPI/A2UI`](https://github.com/AdaWorldAPI/A2UI) v0.8's
"declarative JSON UI intent payload" is the output side of the same
northstar. Adding it = one more `ArtifactKind::A2uiPayload` variant +
one askama template; the A2UI renderers (Flutter / Angular / Lit) then
consume the canonical layer with no Ruby/Rails coupling.
- **Prior art (no active deps)**: `DUSK_Solution` (multi-renderer
scenes + theme/mood; .NET), `MUIBridge` (bridge pattern, .NET).
Both encode the same insight; kept as design lineage.

## Quick check

```bash
cargo test -p ogar-render-askama # 6/6 unit tests
```

## Layering in the OGAR stack

```text
ogar-vocab (codebook + Class fns) ← source of truth
│ pure construction at build time
ogar-render-askama (this crate — askama emitters per kind)
.rs / .ts / .surql / .json source text ← downstream consumers
```
39 changes: 39 additions & 0 deletions crates/ogar-render-askama/src/artifact_kinds/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Per-kind emitter dispatch. Mirror of `woa-rs::codegen::handler_kinds`.
//!
//! Each [`ArtifactKind`] owns its own emitter. The dispatcher is a tiny
//! `match` over the enum, returning a boxed trait object so the call site
//! is one line:
//!
//! ```ignore
//! let emitter = artifact_kinds::for_kind(spec.kind);
//! let source = emitter.emit(&spec)?;
//! ```
//!
//! Proof-of-shape phase: [`RustStruct`](rust_struct::RustStructEmitter) has
//! a real askama template + emitter; the other four kinds use [`Stub`] —
//! placeholder code that compiles and emits a marker comment so callers can
//! exercise the full pipeline (lookup + dispatch + return) without waiting
//! for every template to land. Concrete emitters arrive per-kind in
//! follow-on PRs (T2–T5 in the integration plan).

use crate::spec::{ArtifactKind, ArtifactSpec};

pub mod rust_struct;
pub mod stub;

/// Contract every kind's emitter implements.
pub trait ArtifactEmitter {
/// Render `spec.class` as the target artifact for this emitter's
/// [`ArtifactKind`]. Returns the emitted source as a `String`;
/// downstream tooling writes it to disk.
fn emit(&self, spec: &ArtifactSpec<'_>) -> Result<String, askama::Error>;
}

/// Dispatch to the concrete emitter for `kind`. Always returns Some
/// emitter — unimplemented kinds fall through to [`Stub`].
pub fn for_kind(kind: ArtifactKind) -> Box<dyn ArtifactEmitter> {
match kind {
ArtifactKind::RustStruct => Box::new(rust_struct::RustStructEmitter),
other => Box::new(stub::Stub { kind: other }),
}
}
181 changes: 181 additions & 0 deletions crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! `RustStruct` emitter — the proof-of-shape concrete renderer.
//!
//! Lifts an [`ogar_vocab::Class`] into a Rust `struct` declaration with a
//! `pub const CLASS_ID: u16` (from the OGAR codebook). The template is
//! `templates/dispatch/rust_struct.askama`; this file is the typed binding
//! between the `Class` data and the template's variables.
//!
//! Mirror of `woa-rs::codegen::handler_kinds::list_for_tenant` (the proof-
//! of-shape concrete emitter that opens the kit).

use askama::Template;

use super::ArtifactEmitter;
use crate::spec::ArtifactSpec;
use ogar_vocab::{canonical_concept_id, AssociationKind};

/// askama-bound context for `templates/dispatch/rust_struct.askama`.
///
/// All `String` fields because the template never branches on Rust types
/// — it just substitutes. Mapping `Attribute.type_name` / `Association`
/// targets onto Rust types happens in [`RustStructEmitter::emit`], not in
/// the template.
#[derive(Template)]
#[template(path = "dispatch/rust_struct.askama", escape = "none")]
struct RustStructCtx {
name: String,
concept_fn: String,
canonical_concept: String,
/// Hex-formatted class id (`"0x0102"`) or empty string when the
/// concept isn't in the codebook (the template branches on length).
class_id_hex: String,
const_name: String,
attributes: Vec<RustAttr>,
associations: Vec<RustEdge>,
}

struct RustAttr {
name: String,
snake_name: String,
rust_type: String,
/// Producer-side type name (curator's `"integer"`, `"big_integer"`,
/// `"Char"`, …), empty when absent.
type_name: String,
}

struct RustEdge {
name: String,
snake_name: String,
rust_type: String,
/// `belongs_to` / `has_one` / `has_many` / `habtm` …
kind_label: String,
target: String,
}

/// The concrete emitter for [`ArtifactKind::RustStruct`](crate::ArtifactKind).
pub struct RustStructEmitter;

impl ArtifactEmitter for RustStructEmitter {
fn emit(&self, spec: &ArtifactSpec<'_>) -> Result<String, askama::Error> {
let class = spec.class;
let concept = class.canonical_concept.as_deref().unwrap_or("");
let class_id_hex = canonical_concept_id(concept)
.map(|id| format!("0x{id:04X}"))
.unwrap_or_default();
// The `pub const class_ids::FOO` upper-snake name (e.g.
// PROJECT_WORK_ITEM). Empty when no codebook id is known.
let const_name = if class_id_hex.is_empty() {
String::new()
} else {
concept.to_ascii_uppercase()
};

let attributes = class
.attributes
.iter()
.map(|a| RustAttr {
name: a.name.clone(),
snake_name: escape_rust_ident(&a.name),
rust_type: rails_to_rust_type(a.type_name.as_deref()),
type_name: a.type_name.clone().unwrap_or_default(),
})
.collect();

let associations = class
.associations
.iter()
.map(|a| RustEdge {
name: a.name.clone(),
snake_name: escape_rust_ident(&a.name),
rust_type: edge_rust_type(a),
kind_label: assoc_label(a.kind),
target: a.class_name.clone().unwrap_or_default(),
})
.collect();

let ctx = RustStructCtx {
name: class.name.clone(),
concept_fn: concept.to_string(),
canonical_concept: concept.to_string(),
class_id_hex,
const_name,
attributes,
associations,
};
ctx.render()
}
}

/// Prefix Rust reserved words with `r#` so a curator-side slot named
/// `type`, `match`, `move`, … emits a legal Rust field identifier.
///
/// Catches codex P1 on PR #78: `project_actor()` ships an attribute named
/// `type` (per Rails STI convention) and the unescaped template would emit
/// `pub type: String,` — illegal. Same hazard for `async` / `await` /
/// `dyn` etc. that newer Rust editions reserved. Conservative list (Rust
/// 2024 strict + reserved-future); a `&str` slot from the canonical layer
/// can never need a non-identifier escape since names are sourced from
/// Rails / Odoo identifiers.
fn escape_rust_ident(name: &str) -> String {
const RESERVED: &[&str] = &[
// Rust 2015+ strict keywords:
"as", "break", "const", "continue", "crate", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
"move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct",
"super", "trait", "true", "type", "unsafe", "use", "where", "while",
// Rust 2018+ strict keywords:
"async", "await", "dyn",
// Reserved-future (lexable but unusable as raw idents anyway):
"abstract", "become", "box", "do", "final", "macro", "override", "priv",
"typeof", "unsized", "virtual", "yield", "try",
];
if RESERVED.contains(&name) {
format!("r#{name}")
} else {
name.to_string()
}
}

/// Map a producer-side Rails type name onto a Rust type for codegen.
///
/// Coarse: this is the proof-of-shape mapping the canonical layer uses
/// today. Each `op-*` / `rm-*` consumer is free to specialise (e.g.
/// `Decimal` vs `f64` for monetary slots) downstream. The point is the
/// canonical contract round-trips; precision is a per-consumer concern.
fn rails_to_rust_type(t: Option<&str>) -> String {
match t {
Some("string") | Some("text") => "String".into(),
Some("integer") | Some("big_integer") | Some("bigint") => "i64".into(),
Some("float") | Some("double") => "f64".into(),
Some("decimal") | Some("monetary") => "f64".into(),
Some("boolean") | Some("bool") => "bool".into(),
Some("date") | Some("datetime") | Some("timestamp") => "String".into(),
Some("json") | Some("jsonb") => "serde_json::Value".into(),
Some(_) | None => "String".into(),
}
}

fn edge_rust_type(a: &ogar_vocab::Association) -> String {
// Coarse: `belongs_to` / `has_one` → `Option<u64>` (FK id),
// `has_many` / `habtm` → `Vec<u64>`. The concrete `op-*` / `rm-*`
// consumer can swap these for typed references downstream.
match a.kind {
AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => "Vec<u64>".into(),
// BelongsTo, HasOne, plus any non-exhaustive future variant —
// default to optional fk id.
_ => "Option<u64>".into(),
}
}

fn assoc_label(k: AssociationKind) -> String {
match k {
AssociationKind::BelongsTo => "belongs_to".into(),
AssociationKind::HasOne => "has_one".into(),
AssociationKind::HasMany => "has_many".into(),
AssociationKind::HasAndBelongsToMany => "has_and_belongs_to_many".into(),
// AssociationKind is #[non_exhaustive]; any future variant lands
// here. The label is a doc-comment only, so a debug-style
// fallback is harmless.
_ => format!("{k:?}").to_ascii_lowercase(),
}
}
33 changes: 33 additions & 0 deletions crates/ogar-render-askama/src/artifact_kinds/stub.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Placeholder emitter — used by [`super::for_kind`] for any
//! [`ArtifactKind`] whose concrete askama template has not yet landed.
//! Mirror of `woa-rs::codegen::handler_kinds::stub`.
//!
//! Emits a single-line marker comment naming the kind + class. The
//! pipeline (lookup, dispatch, return) is exercisable end-to-end against
//! every promoted concept while individual templates are still in flight.

use super::ArtifactEmitter;
use crate::spec::{ArtifactKind, ArtifactSpec};

/// Placeholder emitter — returns a marker comment for any
/// [`ArtifactKind`] whose template has not yet landed.
pub struct Stub {
/// The kind this stub stands in for. Surfaced in the marker comment.
pub kind: ArtifactKind,
}

impl ArtifactEmitter for Stub {
fn emit(&self, spec: &ArtifactSpec<'_>) -> Result<String, askama::Error> {
Ok(format!(
"// {kind} stub — {class} ({concept})\n\
// codegen pending: template not yet landed for this artifact kind.\n",
kind = self.kind.name(),
class = spec.class.name,
concept = spec
.class
.canonical_concept
.as_deref()
.unwrap_or("<no canonical concept>"),
))
}
}
Loading
Loading