From d5cc62213a99deee78daacd25cfb24466f2bb337 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 20:48:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(ogar-render-askama):=20build-time=20co?= =?UTF-8?q?degen=20harness=20=E2=80=94=20mirror=20of=20woa-rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the build-time askama codegen harness over the calcified canonical layer. Structurally a mirror of AdaWorldAPI/woa-rs crates/codegen (RFC-v02-006): the same shape WoA-rs uses for its route codegen, but the typed input is ogar_vocab::Class instead of a JSON-loaded RouteSpec. The kit: - enum ArtifactKind { RustStruct, TsInterface, SurrealqlTable, OpenapiSchema, NodeGuidRoutingArm } — append-only. - trait ArtifactEmitter — one fn `emit(spec) -> Result`. - artifact_kinds::for_kind(kind) -> Box — dispatcher. - templates/dispatch/.askama — one template per kind, no class name ever hardcoded. Phase-0 (this PR): - ArtifactKind::RustStruct: REAL emitter + template. Renders a Rust struct + `pub const CLASS_ID: u16` + family-edge fields from the canonical class, mapping Rails-side type names to coarse Rust types (downstream consumers specialise per their precision needs). - The other 4 kinds: Stub emitter — compilable, emits a marker comment, exercises the full pipeline (lookup + dispatch + return) before the concrete templates land. The 800 -> 7-70 collapse: adding a new canonical concept costs ZERO new templates (flows through the existing kit). Adding a new target is one ArtifactKind variant + one askama template; every promoted concept emits through it automatically. Decoupled from ogar-class-view (PR #77): - ogar-render-askama is BUILD-TIME (consumes Class, emits source files). - ogar-class-view is RUN-TIME (resolves SoA rows through ClassView trait to render rows for live projection). Both pipelines are askama-templated; both share the N3 field order convention; they consume different shapes — they don't compete. A2UI integration (AdaWorldAPI/A2UI v0.8) carried as T6 on the roadmap only — same northstar from the output side, deferred. DUSK_Solution / MUIBridge noted as design lineage, no active deps. Tests: 6/6 unit + 1 doctest (ignored — askama derive). Workspace check and workspace test green. --- Cargo.toml | 1 + crates/ogar-render-askama/Cargo.toml | 10 + crates/ogar-render-askama/README.md | 78 +++++++ .../src/artifact_kinds/mod.rs | 39 ++++ .../src/artifact_kinds/rust_struct.rs | 151 +++++++++++++ .../src/artifact_kinds/stub.rs | 33 +++ crates/ogar-render-askama/src/lib.rs | 201 ++++++++++++++++++ crates/ogar-render-askama/src/spec.rs | 71 +++++++ .../templates/dispatch/rust_struct.askama | 30 +++ 9 files changed, 614 insertions(+) create mode 100644 crates/ogar-render-askama/Cargo.toml create mode 100644 crates/ogar-render-askama/README.md create mode 100644 crates/ogar-render-askama/src/artifact_kinds/mod.rs create mode 100644 crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs create mode 100644 crates/ogar-render-askama/src/artifact_kinds/stub.rs create mode 100644 crates/ogar-render-askama/src/lib.rs create mode 100644 crates/ogar-render-askama/src/spec.rs create mode 100644 crates/ogar-render-askama/templates/dispatch/rust_struct.askama diff --git a/Cargo.toml b/Cargo.toml index 32e9f71..22b3825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/ogar-from-ruff", "crates/ogar-from-rails", "crates/ogar-class-view", + "crates/ogar-render-askama", ] [workspace.package] diff --git a/crates/ogar-render-askama/Cargo.toml b/crates/ogar-render-askama/Cargo.toml new file mode 100644 index 0000000..c655ef8 --- /dev/null +++ b/crates/ogar-render-askama/Cargo.toml @@ -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" diff --git a/crates/ogar-render-askama/README.md b/crates/ogar-render-askama/README.md new file mode 100644 index 0000000..fbe5e31 --- /dev/null +++ b/crates/ogar-render-askama/README.md @@ -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` | 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`, 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 +``` diff --git a/crates/ogar-render-askama/src/artifact_kinds/mod.rs b/crates/ogar-render-askama/src/artifact_kinds/mod.rs new file mode 100644 index 0000000..f08e117 --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/mod.rs @@ -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; +} + +/// Dispatch to the concrete emitter for `kind`. Always returns Some +/// emitter — unimplemented kinds fall through to [`Stub`]. +pub fn for_kind(kind: ArtifactKind) -> Box { + match kind { + ArtifactKind::RustStruct => Box::new(rust_struct::RustStructEmitter), + other => Box::new(stub::Stub { kind: other }), + } +} diff --git a/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs new file mode 100644 index 0000000..b2b654b --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs @@ -0,0 +1,151 @@ +//! `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, + associations: Vec, +} + +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 { + 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: a.name.clone(), + 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: a.name.clone(), + 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() + } +} + +/// 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` (FK id), + // `has_many` / `habtm` → `Vec`. The concrete `op-*` / `rm-*` + // consumer can swap these for typed references downstream. + match a.kind { + AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => "Vec".into(), + // BelongsTo, HasOne, plus any non-exhaustive future variant — + // default to optional fk id. + _ => "Option".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(), + } +} diff --git a/crates/ogar-render-askama/src/artifact_kinds/stub.rs b/crates/ogar-render-askama/src/artifact_kinds/stub.rs new file mode 100644 index 0000000..e48ee0a --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/stub.rs @@ -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 { + 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(""), + )) + } +} diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs new file mode 100644 index 0000000..868e8a1 --- /dev/null +++ b/crates/ogar-render-askama/src/lib.rs @@ -0,0 +1,201 @@ +//! `ogar-render-askama` — build-time codegen harness over the calcified +//! canonical layer. +//! +//! Structurally a mirror of [`AdaWorldAPI/woa-rs`](https://github.com/AdaWorldAPI/woa-rs) +//! `crates/codegen` (RFC-v02-006): one [`ArtifactKind`] enum dispatched +//! through a per-kind [`ArtifactEmitter`] trait, with one askama template +//! per kind. The canonical input here is [`ogar_vocab::Class`] instead of +//! WoA's `RouteSpec`, but the kit shape is the same. +//! +//! # The 800 → 7-70 collapse +//! +//! The number of templates is bounded by **artifact kind**, never by +//! `(class × target)`. Adding a new canonical concept (e.g. promoting +//! `project_costs`) is one ogar-vocab class fn + zero new templates — the +//! existing kit renders it through. Adding a new target (gremlin, proto, +//! …) is one new [`ArtifactKind`] variant + one askama template; every +//! promoted concept emits through it automatically. +//! +//! # Layering (where this lives in the OGAR stack) +//! +//! ```text +//! ogar-vocab (codebook + Class fns) +//! │ +//! │ pure construction at build time +//! ▼ +//! ogar-render-askama (THIS CRATE — askama-bound emitters per kind) +//! │ +//! │ .rs / .ts / .surql / .json source text +//! ▼ +//! downstream consumers (op-codegen-projection, rm-codegen, medcare, …) +//! ``` +//! +//! `ClassView` (the **run-time** projection layer in `lance-graph-contract`) +//! is a sibling concern: it materialises a SoA row's render rows at query +//! time. Both pipelines are jinja-templated; both share the N3 field order +//! convention; they consume different shapes. This crate handles the +//! build-time path (typed source emission); `ogar-class-view` handles the +//! run-time path (label-resolved row projection). +//! +//! # Proof-of-shape phase +//! +//! [`ArtifactKind::RustStruct`] has a real askama template + concrete +//! emitter. The other four kinds use [`artifact_kinds::stub::Stub`] — +//! placeholder code that compiles and emits a marker comment so callers +//! can exercise the full pipeline against every promoted concept while +//! T2–T5 templates land in follow-on PRs. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod artifact_kinds; +pub mod spec; + +pub use artifact_kinds::{for_kind, ArtifactEmitter}; +pub use spec::{ArtifactKind, ArtifactSpec}; + +use ogar_vocab::Class; + +/// Render one artifact in one call. Convenience over +/// [`artifact_kinds::for_kind`] + the emitter trait. +pub fn render(class: &Class, kind: ArtifactKind) -> Result { + let spec = ArtifactSpec::new(class, kind); + for_kind(kind).emit(&spec) +} + +/// Render every promoted concept for one [`ArtifactKind`], returning +/// `(canonical_concept, source)` pairs. Useful for batch codegen of a +/// full target (e.g. emit every concept as a Rust struct). +/// +/// Walks the same 32-concept set [`crate::artifact_kinds`]'s tests use. +/// Concepts without a `canonical_concept` field are skipped. +pub fn render_all( + classes: &[Class], + kind: ArtifactKind, +) -> Result, askama::Error> { + let emitter = for_kind(kind); + let mut out = Vec::with_capacity(classes.len()); + for class in classes { + let Some(concept) = class.canonical_concept.clone() else { + continue; + }; + let spec = ArtifactSpec::new(class, kind); + let source = emitter.emit(&spec)?; + out.push((concept, source)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::{ + billable_work_entry, project, project_role, project_work_item, + }; + + #[test] + fn artifact_kind_all_const_enumerates_every_variant() { + // Pin: `ArtifactKind::ALL` enumerates every variant. New variants + // must be appended here (the enum + the const slice both change). + let all = ArtifactKind::ALL; + assert!( + all.contains(&ArtifactKind::RustStruct) + && all.contains(&ArtifactKind::TsInterface) + && all.contains(&ArtifactKind::SurrealqlTable) + && all.contains(&ArtifactKind::OpenapiSchema) + && all.contains(&ArtifactKind::NodeGuidRoutingArm), + "ArtifactKind::ALL missing a variant" + ); + assert_eq!(all.len(), 5); + } + + #[test] + fn rust_struct_emits_pub_const_class_id_for_promoted_concept() { + // Proof of shape: render project_work_item — verify the emitted + // source declares the right struct name + CLASS_ID + canonical + // concept. + let class = project_work_item(); + let src = render(&class, ArtifactKind::RustStruct).unwrap(); + assert!(src.contains("pub struct ProjectWorkItem"), "{src}"); + assert!( + src.contains("pub const CLASS_ID: u16 = 0x0102;"), + "expected CLASS_ID = 0x0102 in:\n{src}" + ); + assert!( + src.contains("pub const CANONICAL_CONCEPT: &str = \"project_work_item\";"), + "{src}" + ); + // Doc comment should reference the class fn name. + assert!(src.contains("project_work_item()"), "{src}"); + } + + #[test] + fn rust_struct_emits_family_edges() { + // billable_work_entry has its 12 family edges; the emitted struct + // must surface them as fields (Vec for has_many, + // Option for belongs_to/has_one). + let class = billable_work_entry(); + let src = render(&class, ArtifactKind::RustStruct).unwrap(); + for edge in &class.associations { + assert!( + src.contains(&format!("pub {}:", edge.name)), + "billable_work_entry rust_struct missing family edge `{}`:\n{src}", + edge.name + ); + } + } + + #[test] + fn rust_struct_emits_typed_attribute_for_each_class_attribute() { + // project_role has typed attributes (name, position, permissions). + // Every one must appear in the emitted struct. + let class = project_role(); + let src = render(&class, ArtifactKind::RustStruct).unwrap(); + for attr in &class.attributes { + assert!( + src.contains(&format!("pub {}:", attr.name)), + "project_role rust_struct missing attribute `{}`:\n{src}", + attr.name + ); + } + } + + #[test] + fn stub_emits_marker_for_unimplemented_kinds() { + // The four stub kinds compile + emit a marker comment naming the + // kind + class. This is what lets the kit be wired end-to-end + // before every template has landed. + let class = project(); + for kind in [ + ArtifactKind::TsInterface, + ArtifactKind::SurrealqlTable, + ArtifactKind::OpenapiSchema, + ArtifactKind::NodeGuidRoutingArm, + ] { + let src = render(&class, kind).unwrap(); + assert!( + src.contains(kind.name()), + "stub for {:?} should mention its name:\n{src}", + kind + ); + assert!( + src.contains("Project"), + "stub should mention the class name:\n{src}" + ); + } + } + + #[test] + fn render_all_walks_a_slice_of_classes() { + let classes = vec![project(), project_work_item(), project_role()]; + let out = render_all(&classes, ArtifactKind::RustStruct).unwrap(); + assert_eq!(out.len(), 3); + let concepts: Vec<&str> = out.iter().map(|(c, _)| c.as_str()).collect(); + assert!(concepts.contains(&"project")); + assert!(concepts.contains(&"project_work_item")); + assert!(concepts.contains(&"project_role")); + for (_, src) in &out { + assert!(src.contains("pub const CLASS_ID:"), "{src}"); + } + } +} diff --git a/crates/ogar-render-askama/src/spec.rs b/crates/ogar-render-askama/src/spec.rs new file mode 100644 index 0000000..a17e9f1 --- /dev/null +++ b/crates/ogar-render-askama/src/spec.rs @@ -0,0 +1,71 @@ +//! Per-render spec — the typed input each [`ArtifactKind`] emitter consumes. +//! +//! Mirror of `woa-rs::codegen::spec::RouteSpec` but for the canonical-layer +//! pipeline: the input is an [`ogar_vocab::Class`] (the calcified AR shape), +//! not a JSON-loaded `RouteSpec`. Codegen reads the class fns at build time +//! and dispatches each through an emitter for the chosen +//! [`ArtifactKind`]. + +use ogar_vocab::Class; + +/// The set of target artifacts the render kit can emit per canonical class. +/// New kinds are appended (never reordered) so the dispatcher stays +/// backward-compatible. Mirrors WoA-rs's `HandlerKind` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArtifactKind { + /// Rust `struct` definition + `pub const CLASS_ID: u16` constant. + RustStruct, + /// TypeScript `interface` declaration + matching `class_ids.ts` entry. + TsInterface, + /// SurrealQL `DEFINE TABLE` + per-field `DEFINE FIELD` statements. + SurrealqlTable, + /// OpenAPI 3.1 `components.schemas.{Class}` JSON object. + OpenapiSchema, + /// Rust `match` arm dispatching on `ClassId` — useful for routing on + /// `NodeGuid::classid` in graph consumers. + NodeGuidRoutingArm, +} + +impl ArtifactKind { + /// All kinds in declaration order. Stable across additions (the + /// `ArtifactKind` enum is treated append-only). + pub const ALL: &'static [Self] = &[ + Self::RustStruct, + Self::TsInterface, + Self::SurrealqlTable, + Self::OpenapiSchema, + Self::NodeGuidRoutingArm, + ]; + + /// Human-readable short name — used in stub emitters' marker comments + /// and in `cargo doc` text. + pub fn name(self) -> &'static str { + match self { + Self::RustStruct => "rust_struct", + Self::TsInterface => "ts_interface", + Self::SurrealqlTable => "surrealql_table", + Self::OpenapiSchema => "openapi_schema", + Self::NodeGuidRoutingArm => "node_guid_routing_arm", + } + } +} + +/// One render request: emit `class` as `kind`. +/// +/// Borrow-based so a caller iterating the full codebook does not allocate +/// 32 Class copies per artifact kind. +#[derive(Debug, Clone, Copy)] +pub struct ArtifactSpec<'a> { + /// The canonical class to render. + pub class: &'a Class, + /// Which target artifact to emit. + pub kind: ArtifactKind, +} + +impl<'a> ArtifactSpec<'a> { + /// Pair a `class` with a target `kind`. No allocation; the `class` + /// reference outlives the spec. + pub fn new(class: &'a Class, kind: ArtifactKind) -> Self { + Self { class, kind } + } +} diff --git a/crates/ogar-render-askama/templates/dispatch/rust_struct.askama b/crates/ogar-render-askama/templates/dispatch/rust_struct.askama new file mode 100644 index 0000000..01ec4e0 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/rust_struct.askama @@ -0,0 +1,30 @@ +{# Render one canonical concept as a Rust struct + class-id constant. #} +{# Driven entirely by the askama context — no class name is hardcoded. #} +{# Mirror of `woa-rs::templates::_dispatch::list_view.html`. #} +//! `{{ name }}` — canonical class generated from `ogar_vocab::{{ concept_fn }}()`. +//! DO NOT EDIT BY HAND. Re-render via `ogar-render-askama`. + +/// Canonical codebook id for [`{{ name }}`] — `ogar_vocab::class_ids::{{ const_name }}`. +{% if class_id_hex.len() > 0 -%} +pub const CLASS_ID: u16 = {{ class_id_hex }}; +{% else -%} +// (no codebook id — concept not yet promoted) +{% endif %} + +/// Canonical concept name as in the OGAR codebook. +pub const CANONICAL_CONCEPT: &str = "{{ canonical_concept }}"; + +#[derive(Debug, Clone, PartialEq)] +pub struct {{ name }} { +{%- for attr in attributes %} + /// `{{ attr.name }}`{% if attr.type_name.len() > 0 %} (curator type: `{{ attr.type_name }}`){% endif %}. + pub {{ attr.snake_name }}: {{ attr.rust_type }}, +{%- endfor %} +{%- if associations.len() > 0 %} + // Family edges (canonical relations): +{%- for edge in associations %} + /// {{ edge.kind_label }} `{{ edge.name }}`{% if edge.target.len() > 0 %} → `{{ edge.target }}`{% endif %}. + pub {{ edge.snake_name }}: {{ edge.rust_type }}, +{%- endfor %} +{%- endif %} +} From 5ed58800ceff3d70c1f02d3d70e9bee1bc5031bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 21:06:38 +0000 Subject: [PATCH 2/2] fix(ogar-render-askama): raw-escape Rust keywords in field names (codex P1 on #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project_actor() ships an attribute named `type` (Rails STI convention), which the naive template emitted as `pub type: String,` — illegal Rust. Conservative escape list (Rust 2024 strict + reserved-future) applied in escape_rust_ident(); curator slots like `type` / `match` / `move` / etc. now render as `pub r#type:` / `pub r#match:` / `pub r#move:` and compile. Regression test pinned: project_actor's `type` attribute must emit `pub r#type:` and must NOT emit `pub type:`. 7/7 unit tests green. --- .../src/artifact_kinds/rust_struct.rs | 34 +++++++++++++++++-- crates/ogar-render-askama/src/lib.rs | 26 +++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs index b2b654b..9da2d1a 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs @@ -75,7 +75,7 @@ impl ArtifactEmitter for RustStructEmitter { .iter() .map(|a| RustAttr { name: a.name.clone(), - snake_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(), }) @@ -86,7 +86,7 @@ impl ArtifactEmitter for RustStructEmitter { .iter() .map(|a| RustEdge { name: a.name.clone(), - snake_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(), @@ -106,6 +106,36 @@ impl ArtifactEmitter for RustStructEmitter { } } +/// 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 diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs index 868e8a1..ac8616b 100644 --- a/crates/ogar-render-askama/src/lib.rs +++ b/crates/ogar-render-askama/src/lib.rs @@ -90,7 +90,7 @@ pub fn render_all( mod tests { use super::*; use ogar_vocab::{ - billable_work_entry, project, project_role, project_work_item, + billable_work_entry, project, project_actor, project_role, project_work_item, }; #[test] @@ -185,6 +185,30 @@ mod tests { } } + #[test] + fn rust_struct_escapes_keyword_attribute_names() { + // Codex P1 on #78: `project_actor()` declares an attribute named + // `type` (Rails STI convention). The naive template would emit + // `pub type: String,` which is illegal Rust. The emitter must + // raw-escape Rust reserved words so the output compiles. + let class = project_actor(); + assert!( + class.attributes.iter().any(|a| a.name == "type"), + "regression precondition: project_actor must ship a `type` attribute" + ); + let src = render(&class, ArtifactKind::RustStruct).unwrap(); + // The illegal form must NOT appear ... + assert!( + !src.contains("pub type:"), + "rust_struct must not emit `pub type:` (illegal); got:\n{src}" + ); + // ... and the raw-escaped form MUST appear. + assert!( + src.contains("pub r#type:"), + "expected raw-escaped `pub r#type:` for the `type` attribute:\n{src}" + ); + } + #[test] fn render_all_walks_a_slice_of_classes() { let classes = vec![project(), project_work_item(), project_role()];