Skip to main content

cairn_core/
scaffold.rs

1//! The structural scaffolder — Cairn's analogue of Rails generators.
2//! Records are nominal and monomorphic, so the *mechanical* CRUD shell
3//! (`*_from_rows`, `save`/`save_step`, table DDL) would otherwise be
4//! hand-duplicated per entity. This generates that shell's AST from
5//! one declarative [`EntitySpec`].
6//!
7//! It is **not** a text-template generator (Cairn has no source files
8//! to scaffold) and **not** a language generic. It is deterministic
9//! AST generation: the *same structure a careful author would emit*,
10//! from one spec. Because the store is content-addressed, "same
11//! structure" means *same hash* — so the acceptance is exact: an app
12//! refactored to generate its shell via this module renders
13//! **byte-identical** and its byte-pinned tests pass **unchanged**.
14//! That suite is the wall that keeps generation from drifting.
15//!
16//! Scope is a firm boundary: the scaffolder emits the mechanical
17//! *shell* only — the `*_from_rows` load parser and the
18//! `save`/`save_step` DELETE+re-INSERT persist pair. The business
19//! logic — state machines, `Decimal` money math, relations, role
20//! gates, validation — is not boilerplate and stays hand-authored.
21//! The one shell→logic seam is a `Variant` field's decode/encode
22//! function *names*, declared in the spec. (Rationale and the
23//! boundary record: `docs/design.md` §8/§9.)
24
25use crate::edit::{ExprSpec, FunctionSpec, StepSpec, TypeDefSpec};
26use crate::node::{BinOp, Param, Produces};
27use crate::ty::{Confidence, Effect, Type};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30
31/// How a stored TEXT column round-trips to/from a record field. The
32/// closed set the four real CRM entities use — no kind no real entity
33/// needs.
34#[derive(Clone, Debug, Serialize, Deserialize)]
35pub enum FieldKind {
36    /// Integer column. decode `StrToNumber(field)`, encode
37    /// `NumberToStr(value)`.
38    Num,
39    /// Text column. decode = the field verbatim, encode = the value
40    /// verbatim.
41    Text,
42    /// `Decimal` stored as its raw mantissa. decode
43    /// `IntToDecimal(StrToNumber(field)) / 10000` (the documented
44    /// round-trip), encode `NumberToStr(DecimalRaw(value))`.
45    Decimal,
46    /// A named-variant field round-tripped by app-provided pure
47    /// functions (the one shell→logic seam, by name) — e.g. the
48    /// `Status` machine: `decode = "status_of_str"`, `encode =
49    /// "status_to_str"`.
50    Variant { decode: String, encode: String },
51}
52
53/// One column: the record field name, the DB column name (usually
54/// equal — they differ for `LineItem.unit_price` ↔ `price_raw`), and
55/// the round-trip kind. Fields are listed in column order (= the
56/// SELECT/INSERT order).
57#[derive(Clone, Debug, Serialize, Deserialize)]
58pub struct FieldSpec {
59    pub field: String,
60    pub column: String,
61    pub kind: FieldKind,
62}
63
64impl FieldSpec {
65    /// Sugar: column name equals the field name (the common case).
66    pub fn new(field: &str, kind: FieldKind) -> Self {
67        FieldSpec {
68            field: field.into(),
69            column: field.into(),
70            kind,
71        }
72    }
73    /// Field and column names differ (`unit_price` ↔ `price_raw`).
74    pub fn col(field: &str, column: &str, kind: FieldKind) -> Self {
75        FieldSpec {
76            field: field.into(),
77            column: column.into(),
78            kind,
79        }
80    }
81}
82
83/// One entity's shape. Names are bespoke per entity (the CRM uses
84/// `contacts_from_rows`/`crm_save_lines`, and save-param names
85/// `cs`/`js`/`xs`) — carried here so generation is byte-identical to
86/// the hand form, not normalised.
87#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct EntitySpec {
89    pub record: String,
90    pub table: String,
91    pub rows_fn: String,
92    pub save_fn: String,
93    pub save_param: String,
94    pub fields: Vec<FieldSpec>,
95}
96
97/// `save`/`save_step` use these as internal step/index bindings. A
98/// `save_param` equal to one of them shadows the generator's own
99/// code and produces a type violation in functions the author never
100/// wrote — fail *closed* on it instead.
101const RESERVED_SAVE_PARAMS: &[&str] = &["i", "n", "ins"];
102
103impl EntitySpec {
104    /// Reject a spec that would generate corrupt or self-shadowing
105    /// code, with a message that names the fix — rather than silently
106    /// emitting broken SQL (e.g. a `SELECT … ORDER BY id` over
107    /// columns that include no `id`) or a type violation inside the
108    /// generated code. A well-formed spec is the caller's to get
109    /// right; a malformed one must fail loudly at the seam, never
110    /// corrupt the store. (Typical specs pass unchanged: first field
111    /// is the `id` PK, save-param is not a reserved binding.)
112    pub fn validate(&self) -> Result<(), String> {
113        if self.fields.is_empty() {
114            return Err(
115                "EntitySpec.fields is empty: an entity needs at \
116                 least an `id` column"
117                    .into(),
118            );
119        }
120        // `create_table` makes field 0 the `id INTEGER PRIMARY KEY`
121        // and `select_all` is `… ORDER BY id`; a first column that is
122        // not `id` yields SQL that orders by a column it never
123        // selects — the silent corruption when the `FieldSpec` middle
124        // element (the SQL *column name*, not a Cairn type) is wrong.
125        if self.fields[0].column != "id" {
126            return Err(format!(
127                "EntitySpec.fields[0] column is `{}`, must be `id`: \
128                 the first field is the INTEGER PRIMARY KEY and the \
129                 generated SELECT is `ORDER BY id`. A FieldSpec is \
130                 [field_name, sql_column_name, kind] — the middle \
131                 element is the column name, NOT a Cairn type.",
132                self.fields[0].column
133            ));
134        }
135        if RESERVED_SAVE_PARAMS.contains(&self.save_param.as_str()) {
136            return Err(format!(
137                "EntitySpec.save_param `{}` collides with a binding \
138                 the generated save/save_step use internally \
139                 ({:?}); pick another name (the CRM uses \
140                 `cs`/`js`/`xs`).",
141                self.save_param, RESERVED_SAVE_PARAMS
142            ));
143        }
144        Ok(())
145    }
146}
147
148// --- byte-exact builders (mirror the app-local terse builders the
149// CRM hand-used, so generated AST == hand-authored AST node-for-node)
150
151fn rref(n: &str) -> ExprSpec {
152    ExprSpec::Ref(n.into())
153}
154fn call(func: &str, args: Vec<ExprSpec>) -> ExprSpec {
155    ExprSpec::Call {
156        func: func.into(),
157        args,
158    }
159}
160fn bin(op: BinOp, a: ExprSpec, b: ExprSpec) -> ExprSpec {
161    ExprSpec::BinOp {
162        op,
163        lhs: Box::new(a),
164        rhs: Box::new(b),
165    }
166}
167fn s2n(e: ExprSpec) -> ExprSpec {
168    ExprSpec::StrToNumber(Box::new(e))
169}
170fn n2s(e: ExprSpec) -> ExprSpec {
171    ExprSpec::NumberToStr(Box::new(e))
172}
173fn p(name: &str, ty: Type) -> Param {
174    Param {
175        name: name.into(),
176        ty,
177        min_confidence: Confidence::External,
178    }
179}
180fn ext(ty: Type) -> Produces {
181    Produces {
182        ty,
183        confidence: Confidence::External,
184    }
185}
186fn db_eff() -> BTreeSet<Effect> {
187    [Effect::Db].into_iter().collect()
188}
189/// `field(rows[i], k)` — the framework column accessor.
190fn field_at(k: i64) -> ExprSpec {
191    call(
192        "field",
193        vec![
194            ExprSpec::ListGet {
195                list: Box::new(rref("rows")),
196                index: Box::new(rref("i")),
197            },
198            ExprSpec::Lit(k),
199        ],
200    )
201}
202/// `param[i].FIELD` — the per-row record-field accessor used by save.
203fn item_field(param: &str, record: &str, field: &str) -> ExprSpec {
204    ExprSpec::Field {
205        base: Box::new(ExprSpec::ListGet {
206            list: Box::new(rref(param)),
207            index: Box::new(rref("i")),
208        }),
209        type_name: record.into(),
210        field: field.into(),
211    }
212}
213
214/// Decode column `k` into a record field — byte-identical to the
215/// CRM's hand form (`s2n`/verbatim/`IntToDecimal(s2n)/1e4`/`decoder`).
216fn decode(kind: &FieldKind, k: i64) -> ExprSpec {
217    let f = field_at(k);
218    match kind {
219        FieldKind::Num => s2n(f),
220        FieldKind::Text => f,
221        FieldKind::Decimal => ExprSpec::DecimalOp {
222            op: BinOp::Div,
223            lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(f)))),
224            rhs: Box::new(ExprSpec::Decimal(10000.0)),
225        },
226        FieldKind::Variant { decode, .. } => call(decode, vec![f]),
227    }
228}
229
230/// Encode a record field into its bound `?` param — the exact inverse
231/// of `decode`, byte-identical to the hand form.
232fn encode(fs: &FieldSpec, param: &str, record: &str) -> ExprSpec {
233    let v = item_field(param, record, &fs.field);
234    match &fs.kind {
235        FieldKind::Num => n2s(v),
236        FieldKind::Text => v,
237        FieldKind::Decimal => n2s(ExprSpec::DecimalRaw(Box::new(v))),
238        FieldKind::Variant { encode, .. } => call(encode, vec![v]),
239    }
240}
241
242/// The SQLite column type for a field kind. `Decimal` stores its raw
243/// mantissa as an `INTEGER`; a `Variant` stores its canonical name as
244/// `TEXT`. (The first column is special-cased to the `id` PRIMARY KEY
245/// in `create_table` — every real entity's first field is `id`.)
246fn sql_type(k: &FieldKind) -> &'static str {
247    match k {
248        FieldKind::Num | FieldKind::Decimal => "INTEGER",
249        FieldKind::Text | FieldKind::Variant { .. } => "TEXT",
250    }
251}
252
253/// Generate the `CREATE TABLE IF NOT EXISTS …` DDL — byte-identical
254/// to the hand `*CREATE` constants (first column `id INTEGER PRIMARY
255/// KEY`, the rest `<column> <INTEGER|TEXT>`).
256pub fn create_table(s: &EntitySpec) -> String {
257    let cols: Vec<String> = s
258        .fields
259        .iter()
260        .enumerate()
261        .map(|(i, f)| {
262            if i == 0 {
263                format!("{} INTEGER PRIMARY KEY", f.column)
264            } else {
265                format!("{} {}", f.column, sql_type(&f.kind))
266            }
267        })
268        .collect();
269    format!(
270        "CREATE TABLE IF NOT EXISTS {} ({})",
271        s.table,
272        cols.join(", ")
273    )
274}
275
276/// Generate the `SELECT … FROM … ORDER BY id` the parser consumes —
277/// byte-identical to the hand `*SELECT` constants, columns in the
278/// same order `from_rows` decodes them.
279pub fn select_all(s: &EntitySpec) -> String {
280    let cols: Vec<&str> =
281        s.fields.iter().map(|f| f.column.as_str()).collect();
282    format!(
283        "SELECT {} FROM {} ORDER BY id",
284        cols.join(", "),
285        s.table
286    )
287}
288
289/// Generate the `*_from_rows` recursive load parser.
290pub fn from_rows(s: &EntitySpec) -> FunctionSpec {
291    let elem = Type::Named(s.record.clone());
292    let fields: Vec<(String, ExprSpec)> = s
293        .fields
294        .iter()
295        .enumerate()
296        .map(|(k, fs)| (fs.field.clone(), decode(&fs.kind, k as i64)))
297        .collect();
298    FunctionSpec {
299        name: s.rows_fn.clone(),
300        type_params: vec![],
301        params: vec![
302            p("rows", Type::List(Box::new(Type::String))),
303            p("i", Type::Number),
304        ],
305        produces: ext(Type::List(Box::new(elem.clone()))),
306        requires: BTreeSet::new(),
307        on_failure: vec![],
308        steps: vec![StepSpec {
309            binding: "n".into(),
310            value: ExprSpec::ListLen(Box::new(rref("rows"))),
311        }],
312        result: ExprSpec::If {
313            cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
314            then_branch: Box::new(ExprSpec::ListEmpty {
315                elem: elem.clone(),
316            }),
317            else_branch: Box::new(ExprSpec::ListCons {
318                head: Box::new(ExprSpec::Record {
319                    type_name: s.record.clone(),
320                    fields,
321                }),
322                tail: Box::new(call(
323                    &s.rows_fn,
324                    vec![
325                        rref("rows"),
326                        bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
327                    ],
328                )),
329            }),
330        },
331    }
332}
333
334/// Generate the `(save, save_step)` pair — the DELETE+re-INSERT
335/// persist driver and its one-row INSERT step. Byte-identical to the
336/// hand form (the bespoke `save_fn`/`save_param`/`table`/`column`
337/// names are carried in the spec precisely so).
338pub fn save_pair(s: &EntitySpec) -> (FunctionSpec, FunctionSpec) {
339    let list_ty = Type::List(Box::new(Type::Named(s.record.clone())));
340    let step_fn = format!("{}_step", s.save_fn);
341    let prm = s.save_param.as_str();
342
343    let save = FunctionSpec {
344        name: s.save_fn.clone(),
345        type_params: vec![],
346        params: vec![p(prm, list_ty.clone()), p("i", Type::Number)],
347        produces: ext(Type::Number),
348        requires: db_eff(),
349        on_failure: vec![],
350        steps: vec![StepSpec {
351            binding: "n".into(),
352            value: ExprSpec::ListLen(Box::new(rref(prm))),
353        }],
354        result: ExprSpec::If {
355            cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
356            then_branch: Box::new(ExprSpec::Lit(0)),
357            else_branch: Box::new(call(
358                &step_fn,
359                vec![rref(prm), rref("i")],
360            )),
361        },
362    };
363
364    let cols: Vec<&str> =
365        s.fields.iter().map(|f| f.column.as_str()).collect();
366    let qs: Vec<&str> = s.fields.iter().map(|_| "?").collect();
367    let sql = format!(
368        "INSERT INTO {} ({}) VALUES ({})",
369        s.table,
370        cols.join(", "),
371        qs.join(", "),
372    );
373    let params: Vec<ExprSpec> = s
374        .fields
375        .iter()
376        .map(|fs| encode(fs, prm, &s.record))
377        .collect();
378
379    let step = FunctionSpec {
380        name: step_fn,
381        type_params: vec![],
382        params: vec![p(prm, list_ty), p("i", Type::Number)],
383        produces: ext(Type::Number),
384        requires: db_eff(),
385        on_failure: vec![],
386        steps: vec![StepSpec {
387            binding: "ins".into(),
388            value: ExprSpec::DbQuery {
389                sql: Box::new(ExprSpec::Str(sql)),
390                params: Box::new(ExprSpec::List(params)),
391            },
392        }],
393        result: call(
394            &s.save_fn,
395            vec![
396                rref(prm),
397                bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
398            ],
399        ),
400    };
401    (save, step)
402}
403
404/// The canonical Cairn app shape, declared — Cairn's analogue of a
405/// Rails `rails new` skeleton. It is *not* convention-over-
406/// configuration magic: nothing is discovered by name at runtime. It
407/// is the explicit Elm-Architecture shape every Cairn web app shares
408/// (model record + `Msg` variant + the five pure-ish functions
409/// `run_app` composes + the `route` entry), emitted so an author
410/// reproduces the conventional shape rather than reconstructing it.
411/// The skeleton emits a *working, checkable, runnable* placeholder;
412/// the author fills the model fields, the `Msg` cases + `update`
413/// arms, the real `view`, and real persistence (or composes
414/// [`save_pair`]). Shell only, never the reasoning — the same firm
415/// boundary as the entity scaffolder.
416#[derive(Clone, Debug, Serialize, Deserialize)]
417pub struct AppSpec {
418    /// The app/model name, e.g. `Counter`. The message variant is
419    /// `<app>Msg`; the five TEA functions are prefixed with the
420    /// lowercased name; the served entry is always `route` (the
421    /// CLI-`serve` convention the blog and CRM use).
422    pub app: String,
423}
424
425fn named(n: &str) -> Type {
426    Type::Named(n.into())
427}
428fn no_eff() -> BTreeSet<Effect> {
429    BTreeSet::new()
430}
431fn db_time() -> BTreeSet<Effect> {
432    [Effect::Db, Effect::Time].into_iter().collect()
433}
434fn func(
435    name: &str,
436    params: Vec<Param>,
437    out: Type,
438    requires: BTreeSet<Effect>,
439    result: ExprSpec,
440) -> FunctionSpec {
441    FunctionSpec {
442        name: name.into(),
443        type_params: vec![],
444        params,
445        produces: ext(out),
446        requires,
447        on_failure: vec![],
448        steps: vec![],
449        result,
450    }
451}
452
453/// Generate the canonical TEA app shell from one [`AppSpec`]: the
454/// model record + `<app>Msg` variant, the five functions `run_app`
455/// composes (`*_route_msg`/`*_load`/`*_update`/`*_view`/`*_persist`),
456/// and the `route` entry that wires them through `run_app` by
457/// `FuncRef`. It is **not** standalone:
458/// `Request`/`Response`/`Element`/`run_app`/`render_html` resolve
459/// from the framework splice, exactly like the entity scaffolder's
460/// `field`. The caller composes the returned types + functions into
461/// one `ModuleSpec` with the framework, then `apply_module`. The
462/// placeholders are deliberately the minimal *valid, runnable* shape
463/// (a one-field model, a single `Touch` message, an identity
464/// `update`, a view of state, a no-op `persist`) — a starting point
465/// that type-checks and returns a real `200`, never a stub: filling
466/// it in is the shell→logic seam.
467pub fn app_skeleton(spec: &AppSpec) -> (Vec<TypeDefSpec>, Vec<FunctionSpec>) {
468    let app = spec.app.as_str();
469    let msg = format!("{app}Msg");
470    let pfx = app.to_lowercase();
471
472    let types = vec![
473        TypeDefSpec::Record {
474            name: app.into(),
475            fields: vec![("count".into(), Type::Number)],
476        },
477        TypeDefSpec::Variant {
478            name: msg.clone(),
479            cases: vec![("Touch".into(), vec![])],
480        },
481    ];
482
483    let model = || ExprSpec::Record {
484        type_name: app.into(),
485        fields: vec![("count".into(), ExprSpec::Lit(0))],
486    };
487
488    // route_msg: a real app routes on `req.path`; the skeleton emits
489    // the one message so it checks and runs (the bound permits a
490    // pure route_msg — see `run_app`).
491    let route_msg = func(
492        &format!("{pfx}_route_msg"),
493        vec![p("req", named("Request"))],
494        named(&msg),
495        no_eff(),
496        ExprSpec::Variant {
497            type_name: msg.clone(),
498            case: "Touch".into(),
499            fields: vec![],
500        },
501    );
502    // load: a real app reads persisted state ({Db}); the skeleton
503    // returns the initial model (pure satisfies the {Db} bound).
504    let load = func(
505        &format!("{pfx}_load"),
506        vec![],
507        named(app),
508        no_eff(),
509        model(),
510    );
511    // update: exhaustive Match on the message — a forgotten case is
512    // a check error. Identity until the author fills the arms.
513    let update = func(
514        &format!("{pfx}_update"),
515        vec![p("m", named(app)), p("msg", named(&msg))],
516        named(app),
517        no_eff(),
518        ExprSpec::Match {
519            scrutinee: Box::new(rref("msg")),
520            type_name: msg.clone(),
521            arms: vec![("Touch".into(), vec![], rref("m"))],
522        },
523    );
524    // view: pure Element of state — the canonical view-of-model
525    // pattern, visibly a placeholder until the agent rewrites it.
526    let view = func(
527        &format!("{pfx}_view"),
528        vec![p("m", named(app))],
529        named("Element"),
530        no_eff(),
531        ExprSpec::Variant {
532            type_name: "Element".into(),
533            case: "El".into(),
534            fields: vec![
535                ("tag".into(), ExprSpec::Str("main".into())),
536                (
537                    "kids".into(),
538                    ExprSpec::List(vec![ExprSpec::Variant {
539                        type_name: "Element".into(),
540                        case: "Text".into(),
541                        fields: vec![(
542                            "content".into(),
543                            ExprSpec::StrConcat(
544                                Box::new(ExprSpec::Str(format!(
545                                    "{app} skeleton — fill in view; count="
546                                ))),
547                                Box::new(ExprSpec::NumberToStr(Box::new(
548                                    ExprSpec::Field {
549                                        base: Box::new(rref("m")),
550                                        type_name: app.into(),
551                                        field: "count".into(),
552                                    },
553                                ))),
554                            ),
555                        )],
556                    }]),
557                ),
558            ],
559        },
560    );
561    // persist: a real app writes ({Db}); the skeleton no-ops (pure
562    // satisfies the bound). The agent fills this with real
563    // persistence or composes `scaffold_entity`.
564    let persist = func(
565        &format!("{pfx}_persist"),
566        vec![p("m", named(app))],
567        Type::Number,
568        no_eff(),
569        ExprSpec::Lit(0),
570    );
571    // route: the served entry (the CLI-`serve` convention). Pure
572    // wiring — compose the five through `run_app` by `FuncRef`. Its
573    // effects are `run_app`'s declared `{Db,Time}` bound, unioned
574    // even though the skeleton's functions are pure (the accepted
575    // coarse-bound cost — see `run_app`).
576    let route = func(
577        "route",
578        vec![p("req", named("Request"))],
579        named("Response"),
580        db_time(),
581        ExprSpec::Call {
582            func: "run_app".into(),
583            args: vec![
584                rref("req"),
585                ExprSpec::FuncRef(format!("{pfx}_route_msg")),
586                ExprSpec::FuncRef(format!("{pfx}_load")),
587                ExprSpec::FuncRef(format!("{pfx}_update")),
588                ExprSpec::FuncRef(format!("{pfx}_view")),
589                ExprSpec::FuncRef(format!("{pfx}_persist")),
590            ],
591        },
592    );
593
594    (
595        types,
596        vec![route_msg, load, update, view, persist, route],
597    )
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    fn contact() -> EntitySpec {
605        EntitySpec {
606            record: "Contact".into(),
607            table: "contacts".into(),
608            rows_fn: "contacts_from_rows".into(),
609            save_fn: "crm_save_contacts".into(),
610            save_param: "cs".into(),
611            fields: vec![
612                FieldSpec::new("id", FieldKind::Num),
613                FieldSpec::new("name", FieldKind::Text),
614                FieldSpec::new("phone", FieldKind::Text),
615                FieldSpec::new("kind", FieldKind::Text),
616            ],
617        }
618    }
619
620    /// Byte-exactness vs the hand-authored `contacts_from_rows`.
621    #[test]
622    fn from_rows_is_byte_identical_to_the_hand_form() {
623        let gen = from_rows(&contact());
624        let hand = FunctionSpec {
625            name: "contacts_from_rows".into(),
626            type_params: vec![],
627            params: vec![
628                p("rows", Type::List(Box::new(Type::String))),
629                p("i", Type::Number),
630            ],
631            produces: ext(Type::List(Box::new(Type::Named(
632                "Contact".into(),
633            )))),
634            requires: BTreeSet::new(),
635            on_failure: vec![],
636            steps: vec![StepSpec {
637                binding: "n".into(),
638                value: ExprSpec::ListLen(Box::new(rref("rows"))),
639            }],
640            result: ExprSpec::If {
641                cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
642                then_branch: Box::new(ExprSpec::ListEmpty {
643                    elem: Type::Named("Contact".into()),
644                }),
645                else_branch: Box::new(ExprSpec::ListCons {
646                    head: Box::new(ExprSpec::Record {
647                        type_name: "Contact".into(),
648                        fields: vec![
649                            ("id".into(), s2n(field_at(0))),
650                            ("name".into(), field_at(1)),
651                            ("phone".into(), field_at(2)),
652                            ("kind".into(), field_at(3)),
653                        ],
654                    }),
655                    tail: Box::new(call(
656                        "contacts_from_rows",
657                        vec![
658                            rref("rows"),
659                            bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
660                        ],
661                    )),
662                }),
663            },
664        };
665        assert_eq!(
666            serde_json::to_string(&gen).unwrap(),
667            serde_json::to_string(&hand).unwrap(),
668            "generated *_from_rows must be byte-identical to hand"
669        );
670    }
671
672    /// Byte-exactness vs the hand-authored
673    /// `crm_save_contacts`/`_step`.
674    #[test]
675    fn save_pair_is_byte_identical_to_the_hand_form() {
676        let (save, step) = save_pair(&contact());
677
678        let hand_save = FunctionSpec {
679            name: "crm_save_contacts".into(),
680            type_params: vec![],
681            params: vec![
682                p(
683                    "cs",
684                    Type::List(Box::new(Type::Named("Contact".into()))),
685                ),
686                p("i", Type::Number),
687            ],
688            produces: ext(Type::Number),
689            requires: db_eff(),
690            on_failure: vec![],
691            steps: vec![StepSpec {
692                binding: "n".into(),
693                value: ExprSpec::ListLen(Box::new(rref("cs"))),
694            }],
695            result: ExprSpec::If {
696                cond: Box::new(bin(BinOp::Eq, rref("i"), rref("n"))),
697                then_branch: Box::new(ExprSpec::Lit(0)),
698                else_branch: Box::new(call(
699                    "crm_save_contacts_step",
700                    vec![rref("cs"), rref("i")],
701                )),
702            },
703        };
704        let hand_step = FunctionSpec {
705            name: "crm_save_contacts_step".into(),
706            type_params: vec![],
707            params: vec![
708                p(
709                    "cs",
710                    Type::List(Box::new(Type::Named("Contact".into()))),
711                ),
712                p("i", Type::Number),
713            ],
714            produces: ext(Type::Number),
715            requires: db_eff(),
716            on_failure: vec![],
717            steps: vec![StepSpec {
718                binding: "ins".into(),
719                value: ExprSpec::DbQuery {
720                    sql: Box::new(ExprSpec::Str(
721                        "INSERT INTO contacts (id, name, phone, kind) \
722                         VALUES (?, ?, ?, ?)"
723                            .into(),
724                    )),
725                    params: Box::new(ExprSpec::List(vec![
726                        n2s(item_field("cs", "Contact", "id")),
727                        item_field("cs", "Contact", "name"),
728                        item_field("cs", "Contact", "phone"),
729                        item_field("cs", "Contact", "kind"),
730                    ])),
731                },
732            }],
733            result: call(
734                "crm_save_contacts",
735                vec![
736                    rref("cs"),
737                    bin(BinOp::Add, rref("i"), ExprSpec::Lit(1)),
738                ],
739            ),
740        };
741        assert_eq!(
742            serde_json::to_string(&save).unwrap(),
743            serde_json::to_string(&hand_save).unwrap(),
744            "generated save must be byte-identical to hand"
745        );
746        assert_eq!(
747            serde_json::to_string(&step).unwrap(),
748            serde_json::to_string(&hand_step).unwrap(),
749            "generated save_step must be byte-identical to hand"
750        );
751    }
752
753    #[test]
754    fn ddl_and_select_are_byte_identical_to_the_hand_constants() {
755        // Contact (all columns == fields).
756        assert_eq!(
757            create_table(&contact()),
758            "CREATE TABLE IF NOT EXISTS contacts \
759             (id INTEGER PRIMARY KEY, name TEXT, phone TEXT, kind TEXT)"
760        );
761        assert_eq!(
762            select_all(&contact()),
763            "SELECT id, name, phone, kind FROM contacts ORDER BY id"
764        );
765        // LineItem: column ≠ field (`unit_price`↔`price_raw`),
766        // Decimal→INTEGER.
767        let li = EntitySpec {
768            record: "LineItem".into(),
769            table: "line_items".into(),
770            rows_fn: "lineitems_from_rows".into(),
771            save_fn: "crm_save_lines".into(),
772            save_param: "xs".into(),
773            fields: vec![
774                FieldSpec::new("id", FieldKind::Num),
775                FieldSpec::new("estimate_id", FieldKind::Num),
776                FieldSpec::new("description", FieldKind::Text),
777                FieldSpec::new("qty", FieldKind::Num),
778                FieldSpec::col(
779                    "unit_price",
780                    "price_raw",
781                    FieldKind::Decimal,
782                ),
783            ],
784        };
785        assert_eq!(
786            create_table(&li),
787            "CREATE TABLE IF NOT EXISTS line_items \
788             (id INTEGER PRIMARY KEY, estimate_id INTEGER, \
789             description TEXT, qty INTEGER, price_raw INTEGER)"
790        );
791        assert_eq!(
792            select_all(&li),
793            "SELECT id, estimate_id, description, qty, price_raw \
794             FROM line_items ORDER BY id"
795        );
796    }
797
798    #[test]
799    fn decimal_and_variant_round_trip_match_documented_forms() {
800        // decode Decimal == IntToDecimal(s2n(field))/1e4
801        let d = decode(&FieldKind::Decimal, 4);
802        let want = ExprSpec::DecimalOp {
803            op: BinOp::Div,
804            lhs: Box::new(ExprSpec::IntToDecimal(Box::new(s2n(
805                field_at(4),
806            )))),
807            rhs: Box::new(ExprSpec::Decimal(10000.0)),
808        };
809        assert_eq!(
810            serde_json::to_string(&d).unwrap(),
811            serde_json::to_string(&want).unwrap()
812        );
813        // encode Decimal == n2s(DecimalRaw(value))
814        let fs = FieldSpec::col(
815            "unit_price",
816            "price_raw",
817            FieldKind::Decimal,
818        );
819        let e = encode(&fs, "xs", "LineItem");
820        let want_e = n2s(ExprSpec::DecimalRaw(Box::new(item_field(
821            "xs",
822            "LineItem",
823            "unit_price",
824        ))));
825        assert_eq!(
826            serde_json::to_string(&e).unwrap(),
827            serde_json::to_string(&want_e).unwrap()
828        );
829        // Variant round-trips through the named decode/encode fns.
830        let vk = FieldKind::Variant {
831            decode: "status_of_str".into(),
832            encode: "status_to_str".into(),
833        };
834        assert_eq!(
835            serde_json::to_string(&decode(&vk, 3)).unwrap(),
836            serde_json::to_string(&call(
837                "status_of_str",
838                vec![field_at(3)]
839            ))
840            .unwrap()
841        );
842        let vfs = FieldSpec::new("status", vk);
843        assert_eq!(
844            serde_json::to_string(&encode(&vfs, "js", "Job")).unwrap(),
845            serde_json::to_string(&call(
846                "status_to_str",
847                vec![item_field("js", "Job", "status")]
848            ))
849            .unwrap()
850        );
851    }
852
853    /// The canonical app skeleton is the exact shape `run_app`
854    /// expects: the model + `<app>Msg` types, the five prefixed TEA
855    /// functions all pure (the bound permits it), and `route` wiring
856    /// them by `FuncRef` with `run_app`'s `{Db,Time}` effects. Pins
857    /// the convention so it cannot silently drift (the content-
858    /// addressed analogue of a Rails generator that cannot rot).
859    #[test]
860    fn app_skeleton_emits_the_canonical_tea_shape() {
861        let (types, fns) = app_skeleton(&AppSpec {
862            app: "Counter".into(),
863        });
864
865        // triad types: the model record + the message variant.
866        match &types[0] {
867            TypeDefSpec::Record { name, fields } => {
868                assert_eq!(name, "Counter");
869                assert_eq!(fields[0].0, "count");
870            }
871            _ => panic!("first type is the model record"),
872        }
873        match &types[1] {
874            TypeDefSpec::Variant { name, cases } => {
875                assert_eq!(name, "CounterMsg");
876                assert_eq!(cases[0].0, "Touch");
877            }
878            _ => panic!("second type is the Msg variant"),
879        }
880
881        let names: Vec<&str> =
882            fns.iter().map(|f| f.name.as_str()).collect();
883        assert_eq!(
884            names,
885            vec![
886                "counter_route_msg",
887                "counter_load",
888                "counter_update",
889                "counter_view",
890                "counter_persist",
891                "route",
892            ],
893            "the five TEA functions (prefixed) + the `route` entry"
894        );
895
896        let by = |n: &str| fns.iter().find(|f| f.name == n).unwrap();
897        // `route` carries run_app's declared {Db,Time} bound; the
898        // five composed functions are pure (the bound permits it).
899        let dt: BTreeSet<Effect> =
900            [Effect::Db, Effect::Time].into_iter().collect();
901        assert_eq!(by("route").requires, dt);
902        for n in [
903            "counter_route_msg",
904            "counter_load",
905            "counter_update",
906            "counter_view",
907            "counter_persist",
908        ] {
909            assert!(
910                by(n).requires.is_empty(),
911                "{n} is pure in the skeleton"
912            );
913        }
914        // `route` is the served entry: a Request → Response wiring
915        // that calls run_app with the five FuncRefs in order.
916        match &by("route").result {
917            ExprSpec::Call { func, args } => {
918                assert_eq!(func, "run_app");
919                assert!(matches!(&args[0], ExprSpec::Ref(r) if r == "req"));
920                let refs: Vec<&str> = args[1..]
921                    .iter()
922                    .map(|a| match a {
923                        ExprSpec::FuncRef(n) => n.as_str(),
924                        _ => panic!("run_app args are FuncRefs"),
925                    })
926                    .collect();
927                assert_eq!(
928                    refs,
929                    vec![
930                        "counter_route_msg",
931                        "counter_load",
932                        "counter_update",
933                        "counter_view",
934                        "counter_persist"
935                    ]
936                );
937            }
938            _ => panic!("route composes run_app"),
939        }
940    }
941
942    /// Fail-closed on the malformed-spec cases; pass well-formed
943    /// specs unchanged.
944    #[test]
945    fn entity_spec_validate_rejects_malformed_specs() {
946        // The good CRM `Contact` spec validates.
947        assert!(contact().validate().is_ok());
948
949        // Empty fields.
950        let mut e = contact();
951        e.fields = vec![];
952        assert!(e.validate().is_err());
953
954        // First column not `id` — the silent-corrupt-DDL case
955        // (`["id","Number","Num"]` parsed as column "Number").
956        let bad = EntitySpec {
957            record: "Note".into(),
958            table: "notes".into(),
959            rows_fn: "note_from_rows".into(),
960            save_fn: "note_save".into(),
961            save_param: "notes".into(),
962            fields: vec![
963                FieldSpec::col("id", "Number", FieldKind::Num),
964                FieldSpec::col("body", "String", FieldKind::Text),
965            ],
966        };
967        let err = bad.validate().unwrap_err();
968        assert!(
969            err.contains("must be `id`") && err.contains("NOT a Cairn type"),
970            "names the fix: {err}"
971        );
972
973        // save_param shadowing the generator's internal `n`.
974        let mut collide = contact();
975        collide.save_param = "n".into();
976        assert!(collide.validate().unwrap_err().contains("collides"));
977        // …and the other reserved names.
978        for r in ["i", "ins"] {
979            let mut c = contact();
980            c.save_param = r.into();
981            assert!(c.validate().is_err(), "{r} is reserved");
982        }
983    }
984}