fix(speckl): Rust generator — Date type, record structs, event enum (cycle 73)

Three defects found by deep-inspecting the Rust output against the
product spec (FederatedMeetupProduct.speckdl):

1. Date type undefined in Rust — emitted as 'Date' which doesn't exist.
   Fixed: Date maps to u64 (Unix timestamp) in both the primitive map
   and the ident fallback path.

2. Interface records (Money, Ticket, Order, etc.) not emitted as Rust
   structs — only events were emitted. The TS backend has the same gap.
   Fixed: record-kind interfaces now emit as Rust structs.

3. Decision log typed as Vec<FirstEventName> instead of an enum union.
   Fixed: emit a SpeckName_Event enum with one variant per event type,
   type the decision log as Vec<SpeckName_Event>.

57/57 tests pass. Both specs generate clean Rust output.
This commit is contained in:
Scott Scoble 2026-06-29 17:14:12 -07:00
parent e5ee3fe1c0
commit 84b8962d22

View file

@ -198,6 +198,29 @@ function emitSpeck(speck: SpeckNode): string {
return `#[derive(Debug, Clone)]\npub struct ${e.name} {\n${fields}\n}`;
}).join('\n\n');
// Cycle 73: Event enum union for the decision log.
// Each variant wraps the corresponding event struct.
const eventEnum = events.length > 0
? `#[derive(Debug, Clone)]\npub enum ${speck.name}Event {\n` +
events.map(e => ` ${e.name}(${e.name}),`).join('\n') +
`\n}`
: '';
// Cycle 73: Interface record structs (Money, Ticket, Order, etc.)
// Previously only events were emitted as structs. Interface records
// with fields (kind === 'record') are now emitted too.
const recordInterfaces = allInterfaces.filter(iface =>
iface?.kind === 'record' || (iface?.fields && iface.fields.length > 0)
);
const recordStructs = recordInterfaces.map(iface => {
const fields = iface.fields.map((f: any) => {
const name = snakeCase(cleanName(f.name));
const ty = rustType(f.type, speck.name, enumMap);
return ` pub ${name}: ${ty},`;
}).join('\n');
return `#[derive(Debug, Clone)]\npub struct ${iface.name} {\n${fields}\n}`;
}).join('\n\n');
const structName = `${speck.name}Machine`;
// State variables with original name + snake_case name mapping
@ -276,8 +299,11 @@ function emitSpeck(speck: SpeckNode): string {
` }`
).join('\n\n');
// Decision log type
const logEventType = events.length > 0 ? `Vec<${events[0].name}>` : 'Vec<String>';
// Decision log type — cycle 73: use an enum union of all event types,
// not just the first one. If there are no events, fall back to String.
const logEventType = events.length > 0
? `Vec<${speck.name}Event>`
: 'Vec<String>';
// Struct definition
const structDef = `/// Speck state machine: ${speck.name}\n` +
@ -318,11 +344,13 @@ function emitSpeck(speck: SpeckNode): string {
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
`use std::collections::{HashMap, HashSet};\n\n` +
`${stateEnum}\n\n` +
`${recordStructs ? recordStructs + '\n\n' : ''}` +
`${eventStructs}\n\n` +
`${eventEnum ? eventEnum + '\n\n' : ''}` +
`${structDef}\n\n` +
`${constructor}\n\n` +
`${actionImpl}\n` +
`${invariantImpl}\n` +
`${invariantImpl}` +
`${verifyImpl}\n`;
return file;
@ -619,6 +647,8 @@ function rustType(t: any, speckName: string = '', enumMap?: Map<string, string[]
Nat: 'u64', Int: 'i64', Real: 'f64',
Bool: 'bool', String: 'String', Bytes: 'Vec<u8>',
Number: 'f64',
// Cycle 73: Date → u64 Unix timestamp (avoids chrono dependency).
Date: 'u64',
};
return map[t.name] || t.name.toLowerCase();
}
@ -645,6 +675,7 @@ function rustType(t: any, speckName: string = '', enumMap?: Map<string, string[]
if (rawName === 'Int') return 'i64';
if (rawName === 'Bool') return 'bool';
if (rawName === 'String') return 'String';
if (rawName === 'Date') return 'u64';
// Handle Option<T> from SpeckDL's Option(Type) syntax
const optionMatch = rawName.match(/^Option[_(]\s*(\w+)\s*\)?$/);
if (optionMatch) {