Skip to main content

brink_format/
conventions.rs

1//! The conventions-projection wire shape (issue #2111, continuation
2//! finding 2): a flat, span-free mirror of `brink_ir::ConventionsProjection`
3//! that can round-trip through bytes, in the same hand-rolled
4//! `.inkb`-section-codec idiom `StructShapeDef`/`FrameShapeDef` already use
5//! — never `serde`. Nothing in this crate's `.inkb` format is serde-based
6//! (`definition.rs`'s types derive no `Serialize`/`Deserialize` at all);
7//! `serde` only appears elsewhere in this crate for the unrelated
8//! JSON-based save-game format (`save.rs`) and `Value`'s `serde_json` law
9//! tests. Following the established `.inkb` idiom here — rather than
10//! reaching for `serde` because the review finding's prose said "no
11//! serde" — is the "do not fork a second format" instruction read
12//! literally: a serde-derived type would BE a second, inconsistent
13//! encoding convention living next to every other section in this format.
14//!
15//! # What this module does NOT yet do
16//!
17//! This defines the wire SHAPE and its codec only — it is not yet wired
18//! into [`crate::StoryData`]/[`crate::inkb::SectionKind`]. Doing that
19//! requires threading the project-layer conventions projection —
20//! `brink_ir::ConventionsProjection::from_decls` (built from
21//! `ClaimHandlerDecl`s), surfaced editor-side via `brink_db::queries::
22//! analysis::conventions_projection_query` (#2111/#2212) — through LIR
23//! lowering, which `brink-compiler`'s production pipeline does not do today
24//! (it never runs through `brink-db`'s salsa layer — only the editor does).
25//! The join this used to name, `brink_analyzer::conventions_registry`, was
26//! deleted with the rest of the dissolved `fn conventions()`/`register`
27//! machinery (issue #2165); it is not what this section still needs to
28//! wire up. Allocating a `.inkb` section tag and `StoryData` field ahead of
29//! that consumer (the #2108 host binding join) settling its exact needs
30//! risks locking in the wrong shape; wiring is left to a tracked follow-up.
31//! What exists here —
32//! the types, [`write_conventions_projection`]/[`read_conventions_projection`],
33//! and `brink_ir::ConventionsProjection::to_wire`'s conversion (every field
34//! this wire shape carries survives the round trip — see
35//! [`ConventionEntryDef`]'s own doc for the one field it deliberately does
36//! not carry, and why that is not a loss today) — is the reusable, tested
37//! groundwork that follow-up builds on, not a stand-in for it.
38//!
39//! # `transitions`/`templates` do not live here (2026-08-05 ruling)
40//!
41//! Issue #2115 briefly gave this shape `transitions`/`templates` fields —
42//! `brink_ir::dialect::DialogueDialect`'s editor-overlay succession rows
43//! (Tab/Enter/Shift-Tab behavior, picker labels), carried verbatim. The
44//! 2026-08-05 ruling *"Succession is EDITOR-OWNED and externally defined"*
45//! (`docs/decision-log.md`, PR #2276) reversed that transport: `dialect.rs`
46//! already says these rows "never travel beyond tooling", and `.inkb` **is**
47//! beyond tooling — it is the compiled artifact a game host loads, and a
48//! runtime has no Tab key. Issue #2277 stripped them back out of this wire
49//! shape and its codec. The editor now supplies succession rows externally;
50//! `brink_ir::ConventionsProjection::with_succession` and
51//! `brink_ir::dialect::validate_succession` still re-key those
52//! externally-supplied rows against this projection's real convention kinds
53//! in-process (so a rule naming a nonexistent kind fails loudly) — that
54//! validator half of #2115 survives the ruling intact. Only the wire
55//! transport half was undone.
56
57use alloc::boxed::Box;
58use alloc::string::String;
59use alloc::vec::Vec;
60
61use crate::codec::{
62    read_str, read_u8, read_u32, read_u64, write_str, write_u8, write_u32, write_u64,
63};
64use crate::inkb::safe_capacity;
65use crate::opcode::DecodeError;
66use crate::value::MAX_DECODE_DEPTH;
67
68/// Section-local encoding version — independent of the whole-`.inkb`
69/// `VERSION`, matching every other section-local-versioned table
70/// (`EffectRows`, `AliasTable`, `FrameShapes`) so this section's own
71/// encoding can grow without a format-wide bump once it is wired in.
72///
73/// Bumped `1` → `2` by issue #2115 (`ConventionsProjectionDef` gained
74/// `transitions`/`templates`), then `2` → `3` by issue #2277, which removed
75/// them again per the 2026-08-05 ruling — see this module's own doc.
76/// Nothing has emitted any of these versions into a real `.inkb`/`StoryData`
77/// yet (see this module's own doc — the wire shape is not wired into
78/// `crate::StoryData` at all), so there is no on-disk payload either bump
79/// could orphan; the version byte exists so a future reader can tell the
80/// shapes apart the moment this section *is* wired in, rather than needing
81/// an out-of-band note.
82pub const CONVENTIONS_PROJECTION_WIRE_VERSION: u8 = 3;
83
84/// The conventions projection, as it would ride the wire: every
85/// `@[convention]` handler declared in the project's one configured
86/// conventions module, ascending by `order` — the same shape
87/// `brink_ir::ConventionsProjection` carries in-process, with source spans
88/// stripped (a `.inkb`-loaded host has no source text to point a range at)
89/// and `disposition` intentionally not carried — see
90/// [`ConventionEntryDef`]'s own doc for why.
91///
92/// Does **not** carry `transitions`/`templates` — see this module's own doc
93/// for why (2026-08-05 ruling, issue #2277).
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct ConventionsProjectionDef {
96    pub entries: Vec<ConventionEntryDef>,
97}
98
99/// One `@[convention]` handler's projected shape on the wire. Mirrors
100/// `brink_ir::ConventionProjectionEntry`, with one deliberate exception:
101/// there is no `disposition` field here, because every wire entry is,
102/// today, the single existing `ElementDisposition::Call` case — this format
103/// has no other disposition to distinguish yet, unlike the in-process type,
104/// which carries the field explicitly (`ConventionProjectionEntry::disposition`'s
105/// own "read what happened, don't infer it from absence" reasoning) so a
106/// future second disposition doesn't need a wire bump to become visible
107/// in-process. Adding a second disposition to this wire shape is exactly the
108/// kind of section-local-version bump
109/// [`CONVENTIONS_PROJECTION_WIRE_VERSION`] exists for — until then, this
110/// one field's omission is a considered simplification of a
111/// currently-single-variant enum, not evidence of a lossy conversion
112/// elsewhere.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ConventionEntryDef {
115    pub name: String,
116    pub pattern: String,
117    pub order: i64,
118    pub mode: ConventionModeDef,
119    pub attach: Option<ConventionAttachDef>,
120}
121
122/// Wire mirror of `brink_ir::ConventionMode`.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ConventionModeDef {
125    Attach,
126    Wrap,
127}
128
129/// Wire mirror of `brink_ir::ConventionAttachSchema` — issue #2111 finding
130/// 1's resolved field list, carried all the way to the wire shape rather
131/// than collapsing back to a bare name.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ConventionAttachDef {
134    Resolved {
135        name: String,
136        fields: Vec<ConventionAttachFieldDef>,
137    },
138    Unresolved(String),
139}
140
141/// Wire mirror of `brink_ir::ConventionAttachField`.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ConventionAttachFieldDef {
144    pub name: String,
145    pub ty: SchemaTypeDef,
146}
147
148/// Wire mirror of `brink_ir::SchemaTypeShape`.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum SchemaTypeDef {
151    Named(String),
152    Generic {
153        name: String,
154        args: Vec<SchemaTypeDef>,
155    },
156    Fn {
157        params: Vec<SchemaTypeDef>,
158        ret: Box<SchemaTypeDef>,
159    },
160}
161
162const TAG_ATTACH: u8 = 0;
163const TAG_WRAP: u8 = 1;
164
165const TAG_RESOLVED: u8 = 0;
166const TAG_UNRESOLVED: u8 = 1;
167
168const TAG_TYPE_NAMED: u8 = 0;
169const TAG_TYPE_GENERIC: u8 = 1;
170const TAG_TYPE_FN: u8 = 2;
171
172/// Write the conventions-projection section (no header framing beyond its
173/// own section-local version byte, matching [`CONVENTIONS_PROJECTION_WIRE_VERSION`]):
174/// entry count, then each entry in the order given. Callers sort/dedupe
175/// before calling — this function trusts its input's order, the same
176/// posture every writer in this crate takes toward its own table.
177#[expect(clippy::cast_possible_truncation)]
178pub fn write_conventions_projection(projection: &ConventionsProjectionDef, buf: &mut Vec<u8>) {
179    write_u8(buf, CONVENTIONS_PROJECTION_WIRE_VERSION);
180    write_u32(buf, projection.entries.len() as u32);
181    for entry in &projection.entries {
182        write_str(buf, &entry.name);
183        write_str(buf, &entry.pattern);
184        write_u64(buf, entry.order.cast_unsigned());
185        write_u8(
186            buf,
187            match entry.mode {
188                ConventionModeDef::Attach => TAG_ATTACH,
189                ConventionModeDef::Wrap => TAG_WRAP,
190            },
191        );
192        match &entry.attach {
193            None => write_u8(buf, 0),
194            Some(attach) => {
195                write_u8(buf, 1);
196                write_attach(attach, buf);
197            }
198        }
199    }
200}
201
202#[expect(clippy::cast_possible_truncation)]
203fn write_attach(attach: &ConventionAttachDef, buf: &mut Vec<u8>) {
204    match attach {
205        ConventionAttachDef::Resolved { name, fields } => {
206            write_u8(buf, TAG_RESOLVED);
207            write_str(buf, name);
208            write_u32(buf, fields.len() as u32);
209            for field in fields {
210                write_str(buf, &field.name);
211                write_schema_type(&field.ty, buf);
212            }
213        }
214        ConventionAttachDef::Unresolved(name) => {
215            write_u8(buf, TAG_UNRESOLVED);
216            write_str(buf, name);
217        }
218    }
219}
220
221#[expect(clippy::cast_possible_truncation)]
222fn write_schema_type(ty: &SchemaTypeDef, buf: &mut Vec<u8>) {
223    match ty {
224        SchemaTypeDef::Named(name) => {
225            write_u8(buf, TAG_TYPE_NAMED);
226            write_str(buf, name);
227        }
228        SchemaTypeDef::Generic { name, args } => {
229            write_u8(buf, TAG_TYPE_GENERIC);
230            write_str(buf, name);
231            write_u32(buf, args.len() as u32);
232            for arg in args {
233                write_schema_type(arg, buf);
234            }
235        }
236        SchemaTypeDef::Fn { params, ret } => {
237            write_u8(buf, TAG_TYPE_FN);
238            write_u32(buf, params.len() as u32);
239            for param in params {
240                write_schema_type(param, buf);
241            }
242            write_schema_type(ret, buf);
243        }
244    }
245}
246
247/// Read a conventions-projection section written by
248/// [`write_conventions_projection`]. `buf`/`offset` are a raw cursor, not an
249/// `.inkb`-indexed section range — this codec is not wired into
250/// [`crate::inkb::InkbIndex`] yet (see this module's own doc).
251pub fn read_conventions_projection(
252    buf: &[u8],
253    offset: &mut usize,
254) -> Result<ConventionsProjectionDef, DecodeError> {
255    let section_version = read_u8(buf, offset)?;
256    if section_version != CONVENTIONS_PROJECTION_WIRE_VERSION {
257        return Err(DecodeError::UnsupportedSectionVersion {
258            // No `SectionKind` tag exists for this section yet (see this
259            // module's own doc) — `0` stands in as "not yet a real section".
260            section: 0,
261            version: section_version,
262        });
263    }
264    let count = read_u32(buf, offset)? as usize;
265    // Minimum per-entry footprint: two empty strings (4+4) + order (8) +
266    // mode (1) + no-attach (1) = 18 bytes.
267    let mut entries = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 18));
268    for _ in 0..count {
269        let name = read_str(buf, offset)?;
270        let pattern = read_str(buf, offset)?;
271        let order = read_u64(buf, offset)?.cast_signed();
272        let mode = match read_u8(buf, offset)? {
273            TAG_ATTACH => ConventionModeDef::Attach,
274            TAG_WRAP => ConventionModeDef::Wrap,
275            other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
276        };
277        let attach = match read_u8(buf, offset)? {
278            0 => None,
279            1 => Some(read_attach(buf, offset, 0)?),
280            other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
281        };
282        entries.push(ConventionEntryDef {
283            name,
284            pattern,
285            order,
286            mode,
287            attach,
288        });
289    }
290    Ok(ConventionsProjectionDef { entries })
291}
292
293fn read_attach(
294    buf: &[u8],
295    offset: &mut usize,
296    depth: usize,
297) -> Result<ConventionAttachDef, DecodeError> {
298    match read_u8(buf, offset)? {
299        TAG_RESOLVED => {
300            let name = read_str(buf, offset)?;
301            let count = read_u32(buf, offset)? as usize;
302            // Minimum per-field footprint: empty name (4) + type tag (1) = 5.
303            let mut fields = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 5));
304            for _ in 0..count {
305                let field_name = read_str(buf, offset)?;
306                let ty = read_schema_type(buf, offset, depth)?;
307                fields.push(ConventionAttachFieldDef {
308                    name: field_name,
309                    ty,
310                });
311            }
312            Ok(ConventionAttachDef::Resolved { name, fields })
313        }
314        TAG_UNRESOLVED => Ok(ConventionAttachDef::Unresolved(read_str(buf, offset)?)),
315        other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
316    }
317}
318
319fn read_schema_type(
320    buf: &[u8],
321    offset: &mut usize,
322    depth: usize,
323) -> Result<SchemaTypeDef, DecodeError> {
324    if depth >= MAX_DECODE_DEPTH {
325        return Err(DecodeError::MaxDepthExceeded(MAX_DECODE_DEPTH));
326    }
327    match read_u8(buf, offset)? {
328        TAG_TYPE_NAMED => Ok(SchemaTypeDef::Named(read_str(buf, offset)?)),
329        TAG_TYPE_GENERIC => {
330            let name = read_str(buf, offset)?;
331            let count = read_u32(buf, offset)? as usize;
332            let mut args = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
333            for _ in 0..count {
334                args.push(read_schema_type(buf, offset, depth + 1)?);
335            }
336            Ok(SchemaTypeDef::Generic { name, args })
337        }
338        TAG_TYPE_FN => {
339            let count = read_u32(buf, offset)? as usize;
340            let mut params = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
341            for _ in 0..count {
342                params.push(read_schema_type(buf, offset, depth + 1)?);
343            }
344            let ret = Box::new(read_schema_type(buf, offset, depth + 1)?);
345            Ok(SchemaTypeDef::Fn { params, ret })
346        }
347        other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use alloc::string::ToString;
355    use alloc::vec;
356
357    fn sample() -> ConventionsProjectionDef {
358        ConventionsProjectionDef {
359            entries: vec![
360                ConventionEntryDef {
361                    name: "cue".to_string(),
362                    pattern: "^(?<name>[A-Z]+)$".to_string(),
363                    order: 5,
364                    mode: ConventionModeDef::Wrap,
365                    attach: Some(ConventionAttachDef::Resolved {
366                        name: "Cue".to_string(),
367                        fields: vec![
368                            ConventionAttachFieldDef {
369                                name: "speaker".to_string(),
370                                ty: SchemaTypeDef::Named("string".to_string()),
371                            },
372                            ConventionAttachFieldDef {
373                                name: "voiceover".to_string(),
374                                ty: SchemaTypeDef::Named("bool".to_string()),
375                            },
376                        ],
377                    }),
378                },
379                ConventionEntryDef {
380                    name: "interior".to_string(),
381                    pattern: "^INT\\. (?<place>.+)$".to_string(),
382                    order: 10,
383                    mode: ConventionModeDef::Attach,
384                    attach: None,
385                },
386                ConventionEntryDef {
387                    name: "broken".to_string(),
388                    pattern: "^X$".to_string(),
389                    order: 99,
390                    mode: ConventionModeDef::Attach,
391                    attach: Some(ConventionAttachDef::Unresolved("Ghost".to_string())),
392                },
393            ],
394        }
395    }
396
397    #[test]
398    fn round_trips_a_mixed_projection() {
399        let projection = sample();
400        let mut buf = Vec::new();
401        write_conventions_projection(&projection, &mut buf);
402        let mut offset = 0;
403        let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
404        assert_eq!(decoded, projection);
405        assert_eq!(
406            offset,
407            buf.len(),
408            "reader must consume exactly what the writer wrote"
409        );
410    }
411
412    #[test]
413    fn round_trips_an_empty_projection() {
414        let projection = ConventionsProjectionDef::default();
415        let mut buf = Vec::new();
416        write_conventions_projection(&projection, &mut buf);
417        let mut offset = 0;
418        let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
419        assert_eq!(decoded, projection);
420    }
421
422    #[test]
423    fn round_trips_a_generic_and_fn_typed_field() {
424        let projection = ConventionsProjectionDef {
425            entries: vec![ConventionEntryDef {
426                name: "handler".to_string(),
427                pattern: "^Z$".to_string(),
428                order: 1,
429                mode: ConventionModeDef::Attach,
430                attach: Some(ConventionAttachDef::Resolved {
431                    name: "Fancy".to_string(),
432                    fields: vec![
433                        ConventionAttachFieldDef {
434                            name: "items".to_string(),
435                            ty: SchemaTypeDef::Generic {
436                                name: "List".to_string(),
437                                args: vec![SchemaTypeDef::Named("L".to_string())],
438                            },
439                        },
440                        ConventionAttachFieldDef {
441                            name: "callback".to_string(),
442                            ty: SchemaTypeDef::Fn {
443                                params: vec![SchemaTypeDef::Named("int".to_string())],
444                                ret: Box::new(SchemaTypeDef::Named("bool".to_string())),
445                            },
446                        },
447                    ],
448                }),
449            }],
450        };
451        let mut buf = Vec::new();
452        write_conventions_projection(&projection, &mut buf);
453        let mut offset = 0;
454        let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
455        assert_eq!(decoded, projection);
456    }
457
458    /// Rule 20a/mutation-style: an unknown mode tag must be a decode error,
459    /// not silently coerced to a valid variant.
460    #[test]
461    fn unknown_mode_tag_is_rejected() {
462        let projection = sample();
463        let mut buf = Vec::new();
464        write_conventions_projection(&projection, &mut buf);
465        // The mode byte for the first entry sits right after: version(1) +
466        // count(4) + name(4+len) + pattern(4+len) + order(8). Computed from
467        // the actual fixture strings' lengths (not hardcoded) so this stays
468        // correct if `sample()` ever changes.
469        let first = &projection.entries[0];
470        let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
471        assert_eq!(buf[mode_byte_offset], TAG_WRAP, "test fixture assumption");
472        buf[mode_byte_offset] = 0xFF;
473        let mut offset = 0;
474        let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
475        assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
476    }
477
478    /// Companion to `unknown_mode_tag_is_rejected`: corrupts the byte right
479    /// after the mode tag — the attach-presence flag — so the attach-tag
480    /// match's `other` arm is exercised independently. Before this pair, a
481    /// wrong offset made the "mode" test actually corrupt this byte instead,
482    /// leaving the mode match's `other` arm with zero coverage.
483    #[test]
484    fn unknown_attach_presence_tag_is_rejected() {
485        let projection = sample();
486        let mut buf = Vec::new();
487        write_conventions_projection(&projection, &mut buf);
488        let first = &projection.entries[0];
489        let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
490        let attach_presence_offset = mode_byte_offset + 1;
491        assert_eq!(
492            buf[attach_presence_offset], 1,
493            "test fixture assumption: attach present"
494        );
495        buf[attach_presence_offset] = 0xFF;
496        let mut offset = 0;
497        let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
498        assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
499    }
500
501    /// The section-local version byte is checked independently of decoding
502    /// the rest of the buffer — a future version bump must not be silently
503    /// misread as today's shape.
504    #[test]
505    fn unsupported_section_version_is_rejected() {
506        let mut buf = Vec::new();
507        write_conventions_projection(&ConventionsProjectionDef::default(), &mut buf);
508        buf[0] = CONVENTIONS_PROJECTION_WIRE_VERSION + 1;
509        let mut offset = 0;
510        let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
511        assert_eq!(
512            err,
513            DecodeError::UnsupportedSectionVersion {
514                section: 0,
515                version: CONVENTIONS_PROJECTION_WIRE_VERSION + 1,
516            }
517        );
518    }
519}