Skip to main content

kdl_codegen/parser/
mod.rs

1//! Parser — KDL schema file → [`crate::ir::Schema`].
2//!
3//! Parsing happens in two stages:
4//!
5//! 1. **deserialize** — `club-kdl`'s `KdlDeserialize` derive fills the
6//!    KDL-shaped `raw` structs from the document.
7//! 2. **lower** — `raw` structs are converted into the validated
8//!    [`crate::ir`] representation: enum-like strings become real enums, the
9//!    flat `type` string becomes a [`crate::ir::Ty`], and channel semantics
10//!    (datagram `channel_id` requirements) are checked.
11//!
12//! Only the modern dialect is accepted — `protocol` / `channel` / `request` /
13//! `returns` / `event` / `field`, standalone `struct` / `enum`, and the
14//! entity dialect `record` / `relation` / `id`. Legacy `service` / `method` /
15//! `send` / `recv` constructs are not parsed.
16
17mod raw;
18
19use std::collections::HashSet;
20
21use crate::ir;
22
23/// An error produced while parsing a KDL schema.
24#[derive(Debug, thiserror::Error)]
25pub enum ParseError {
26    /// The input was not well-formed KDL, or did not match the schema shape.
27    #[error("KDL parse error: {0}")]
28    Kdl(String),
29    /// The input parsed as KDL but violated a schema rule.
30    #[error("schema validation error: {0}")]
31    Validation(String),
32}
33
34/// Parse a KDL schema source string into a [`Schema`](ir::Schema).
35///
36/// # Errors
37///
38/// Returns [`ParseError::Kdl`] if the input is not well-formed KDL or does not
39/// match the schema shape, and [`ParseError::Validation`] if it parses but
40/// breaks a schema rule (e.g. a datagram channel without a `channel_id`).
41pub fn parse(src: &str) -> Result<ir::Schema, ParseError> {
42    let raw: raw::RawSchema =
43        club_kdl::from_str(src).map_err(|e| ParseError::Kdl(e.to_string()))?;
44    lower_schema(raw)
45}
46
47// =============================================================================
48// Lowering — raw structs → IR
49// =============================================================================
50
51fn lower_schema(raw: raw::RawSchema) -> Result<ir::Schema, ParseError> {
52    let mut types = Vec::with_capacity(raw.structs.len() + raw.enums.len());
53    for s in raw.structs {
54        types.push(lower_struct(s)?);
55    }
56    for e in raw.enums {
57        types.push(lower_enum(e));
58    }
59    let records = raw
60        .records
61        .into_iter()
62        .map(lower_record)
63        .collect::<Result<Vec<_>, _>>()?;
64    let relations = raw
65        .relations
66        .into_iter()
67        .map(lower_relation)
68        .collect::<Result<Vec<_>, _>>()?;
69    let protocol = raw.protocol.map(lower_protocol).transpose()?;
70    let schema = ir::Schema {
71        types,
72        records,
73        relations,
74        protocol,
75    };
76    validate_type_refs(&schema)?;
77    Ok(schema)
78}
79
80/// The set of type names a schema defines, split by reference kind.
81///
82/// A [`ir::Ty::Named`] (embedded value) must resolve into [`Self::values`];
83/// a [`ir::Ty::Link`] (stored reference) must resolve into [`Self::records`].
84struct DefinedNames<'a> {
85    /// `struct` / `enum` names — valid [`ir::Ty::Named`] targets.
86    values: HashSet<&'a str>,
87    /// `record` names — valid [`ir::Ty::Link`] targets.
88    records: HashSet<&'a str>,
89}
90
91/// Check that every type reference resolves: a [`ir::Ty::Named`] to a defined
92/// `struct` / `enum`, and a [`ir::Ty::Link`] to a defined `record`. Without
93/// this, an unknown type name silently flows through to an emitter and
94/// produces source that fails to compile.
95fn validate_type_refs(schema: &ir::Schema) -> Result<(), ParseError> {
96    let values: HashSet<&str> = schema
97        .types
98        .iter()
99        .map(|t| match t {
100            ir::TypeDef::Struct { name, .. } | ir::TypeDef::Enum { name, .. } => name.as_str(),
101        })
102        .collect();
103    let records: HashSet<&str> = schema.records.iter().map(|r| r.name.as_str()).collect();
104    let defined = DefinedNames { values, records };
105
106    for ty in &schema.types {
107        if let ir::TypeDef::Struct { fields, .. } = ty {
108            check_fields(fields, &defined)?;
109        }
110    }
111    for record in &schema.records {
112        check_fields(&record.fields, &defined)?;
113    }
114    for relation in &schema.relations {
115        check_fields(&relation.fields, &defined)?;
116        // A relation's endpoints must name defined records.
117        for (role, endpoint) in [("from", &relation.from), ("to", &relation.to)] {
118            if !defined.records.contains(endpoint.as_str()) {
119                return Err(ParseError::Validation(format!(
120                    "relation {:?} {role}={endpoint:?} references unknown record; \
121                     define it as a `record`",
122                    relation.name
123                )));
124            }
125        }
126    }
127    if let Some(protocol) = &schema.protocol {
128        for channel in &protocol.channels {
129            for request in &channel.requests {
130                check_fields(&request.fields, &defined)?;
131                if let Some(returns) = &request.returns {
132                    check_fields(&returns.fields, &defined)?;
133                }
134            }
135            for event in &channel.events {
136                check_fields(&event.fields, &defined)?;
137            }
138        }
139    }
140    Ok(())
141}
142
143fn check_fields(fields: &[ir::Field], defined: &DefinedNames) -> Result<(), ParseError> {
144    for field in fields {
145        check_ty(&field.ty, &field.name, defined)?;
146    }
147    Ok(())
148}
149
150fn check_ty(ty: &ir::Ty, field: &str, defined: &DefinedNames) -> Result<(), ParseError> {
151    match ty {
152        ir::Ty::Primitive(_) | ir::Ty::Literal(_) => Ok(()),
153        ir::Ty::Array(inner) => check_ty(inner, field, defined),
154        ir::Ty::Union(members) => members.iter().try_for_each(|m| check_ty(m, field, defined)),
155        ir::Ty::Named(name) if defined.values.contains(name.as_str()) => Ok(()),
156        ir::Ty::Named(name) => Err(ParseError::Validation(format!(
157            "field {field:?} references unknown type {name:?}; \
158             define it as a `struct` or `enum`, or use `link<{name}>` for a `record`"
159        ))),
160        ir::Ty::Link(name) if defined.records.contains(name.as_str()) => Ok(()),
161        ir::Ty::Link(name) => Err(ParseError::Validation(format!(
162            "field {field:?} links to unknown record {name:?}; \
163             define it as a `record`"
164        ))),
165    }
166}
167
168fn lower_struct(raw: raw::RawStruct) -> Result<ir::TypeDef, ParseError> {
169    Ok(ir::TypeDef::Struct {
170        name: raw.name,
171        description: raw.description,
172        fields: lower_fields(raw.fields)?,
173    })
174}
175
176fn lower_enum(raw: raw::RawEnum) -> ir::TypeDef {
177    ir::TypeDef::Enum {
178        name: raw.name,
179        description: raw.description,
180        variants: raw.variants.into_iter().map(|v| v.name).collect(),
181    }
182}
183
184fn lower_record(raw: raw::RawRecord) -> Result<ir::Record, ParseError> {
185    let id_strategy = lower_id_strategy(raw.id.and_then(|i| i.strategy).as_deref())?;
186    Ok(ir::Record {
187        name: raw.name,
188        description: raw.description,
189        id_strategy,
190        fields: lower_fields(raw.fields)?,
191    })
192}
193
194fn lower_relation(raw: raw::RawRelation) -> Result<ir::Relation, ParseError> {
195    Ok(ir::Relation {
196        name: raw.name,
197        description: raw.description,
198        from: raw.from,
199        to: raw.to,
200        unique: raw.unique,
201        fields: lower_fields(raw.fields)?,
202    })
203}
204
205fn lower_id_strategy(s: Option<&str>) -> Result<ir::IdStrategy, ParseError> {
206    match s {
207        None | Some("uuidv7") => Ok(ir::IdStrategy::Uuidv7),
208        Some("ulid") => Ok(ir::IdStrategy::Ulid),
209        Some("manual") => Ok(ir::IdStrategy::Manual),
210        Some(other) => Err(ParseError::Validation(format!(
211            "unknown id `strategy` value {other:?} (expected uuidv7/ulid/manual)"
212        ))),
213    }
214}
215
216fn lower_protocol(raw: raw::RawProtocol) -> Result<ir::Protocol, ParseError> {
217    let channels = raw
218        .channels
219        .into_iter()
220        .map(lower_channel)
221        .collect::<Result<Vec<_>, _>>()?;
222    Ok(ir::Protocol {
223        name: raw.name,
224        version: raw.version,
225        namespace: raw.namespace,
226        description: raw.description,
227        channels,
228    })
229}
230
231fn lower_channel(raw: raw::RawChannel) -> Result<ir::Channel, ParseError> {
232    let from = lower_channel_from(&raw.from)?;
233    let lifetime = lower_channel_lifetime(&raw.lifetime)?;
234    let backend = lower_channel_backend(raw.backend.as_deref())?;
235    let requests = raw
236        .requests
237        .into_iter()
238        .map(lower_request)
239        .collect::<Result<Vec<_>, _>>()?;
240    let events = raw
241        .events
242        .into_iter()
243        .map(lower_event)
244        .collect::<Result<Vec<_>, _>>()?;
245
246    // Semantic validation — mirrors club-unison's `Channel::validate`.
247    if backend == ir::ChannelBackend::Datagram {
248        match raw.channel_id {
249            None => {
250                return Err(ParseError::Validation(format!(
251                    "channel {:?} has backend=\"datagram\" but no channel_id; \
252                     channel_id=N (1..) is required",
253                    raw.name
254                )));
255            }
256            Some(0) => {
257                return Err(ParseError::Validation(format!(
258                    "channel {:?} has channel_id=0 which is reserved; use 1..",
259                    raw.name
260                )));
261            }
262            Some(_) => {}
263        }
264        if !requests.is_empty() {
265            return Err(ParseError::Validation(format!(
266                "channel {:?} has backend=\"datagram\" with request blocks; \
267                 datagram channels support event only (no Request/Response)",
268                raw.name
269            )));
270        }
271    }
272
273    Ok(ir::Channel {
274        name: raw.name,
275        from,
276        lifetime,
277        backend,
278        channel_id: raw.channel_id,
279        envelope: raw.envelope,
280        requests,
281        events,
282    })
283}
284
285fn lower_request(raw: raw::RawRequest) -> Result<ir::Request, ParseError> {
286    Ok(ir::Request {
287        name: raw.name,
288        fields: lower_fields(raw.fields)?,
289        returns: raw.returns.map(lower_message).transpose()?,
290    })
291}
292
293fn lower_event(raw: raw::RawEvent) -> Result<ir::Event, ParseError> {
294    Ok(ir::Event {
295        name: raw.name,
296        fields: lower_fields(raw.fields)?,
297    })
298}
299
300fn lower_message(raw: raw::RawMessage) -> Result<ir::Message, ParseError> {
301    Ok(ir::Message {
302        name: raw.name,
303        fields: lower_fields(raw.fields)?,
304    })
305}
306
307fn lower_fields(raw: Vec<raw::RawField>) -> Result<Vec<ir::Field>, ParseError> {
308    raw.into_iter().map(lower_field).collect()
309}
310
311fn lower_field(raw: raw::RawField) -> Result<ir::Field, ParseError> {
312    Ok(ir::Field {
313        ty: parse_ty(&raw.type_str)?,
314        name: raw.name,
315        required: !raw.optional,
316        flexible: raw.flexible,
317        default: raw.default,
318        description: raw.description,
319        constraints: ir::Constraints {
320            min: raw.min,
321            max: raw.max,
322            min_length: raw.min_length,
323            max_length: raw.max_length,
324            pattern: raw.pattern,
325        },
326    })
327}
328
329// =============================================================================
330// Scalar lowering helpers
331// =============================================================================
332
333fn lower_channel_from(s: &str) -> Result<ir::ChannelFrom, ParseError> {
334    match s {
335        "client" => Ok(ir::ChannelFrom::Client),
336        "server" => Ok(ir::ChannelFrom::Server),
337        "either" => Ok(ir::ChannelFrom::Either),
338        other => Err(ParseError::Validation(format!(
339            "unknown channel `from` value {other:?} (expected client/server/either)"
340        ))),
341    }
342}
343
344fn lower_channel_lifetime(s: &str) -> Result<ir::ChannelLifetime, ParseError> {
345    match s {
346        "transient" => Ok(ir::ChannelLifetime::Transient),
347        "persistent" => Ok(ir::ChannelLifetime::Persistent),
348        other => Err(ParseError::Validation(format!(
349            "unknown channel `lifetime` value {other:?} (expected transient/persistent)"
350        ))),
351    }
352}
353
354fn lower_channel_backend(s: Option<&str>) -> Result<ir::ChannelBackend, ParseError> {
355    match s {
356        None | Some("stream") => Ok(ir::ChannelBackend::Stream),
357        Some("datagram") => Ok(ir::ChannelBackend::Datagram),
358        Some(other) => Err(ParseError::Validation(format!(
359            "unknown channel `backend` value {other:?} (expected stream/datagram)"
360        ))),
361    }
362}
363
364/// Parse a field-type string into a [`Ty`](ir::Ty).
365///
366/// Grammar (lowest precedence first):
367///
368/// - `A | B | ...` — a [`Ty::Union`](ir::Ty::Union). `|` is split only at
369///   the top level (not inside `array<...>` / `link<...>`).
370/// - `array<T>` — a [`Ty::Array`](ir::Ty::Array).
371/// - `link<Name>` — a [`Ty::Link`](ir::Ty::Link), a stored record reference.
372/// - `'literal'` — a [`Ty::Literal`](ir::Ty::Literal) (single-quoted).
373/// - the primitive set — `string` / `int` / `float` / `bool` / `datetime` /
374///   `json`. `object` aliases `json`, `number` aliases `float`, `timestamp`
375///   aliases `datetime`.
376/// - any other identifier — a [`Ty::Named`](ir::Ty::Named) reference to a
377///   `struct` / `enum`.
378fn parse_ty(s: &str) -> Result<ir::Ty, ParseError> {
379    let s = s.trim();
380    if s.is_empty() {
381        return Err(ParseError::Validation("empty field type".to_string()));
382    }
383
384    // Union has the lowest precedence — split on top-level `|`.
385    let members = split_top_level_union(s);
386    if members.len() > 1 {
387        let parsed = members
388            .iter()
389            .map(|m| parse_ty(m))
390            .collect::<Result<Vec<_>, _>>()?;
391        return Ok(ir::Ty::Union(parsed));
392    }
393
394    parse_atom(s)
395}
396
397/// Parse a single (non-union) type atom.
398fn parse_atom(s: &str) -> Result<ir::Ty, ParseError> {
399    let s = s.trim();
400    if let Some(inner) = s.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
401        return Ok(ir::Ty::Array(Box::new(parse_ty(inner)?)));
402    }
403    if let Some(inner) = s.strip_prefix("link<").and_then(|r| r.strip_suffix('>')) {
404        let name = inner.trim();
405        if name.is_empty() {
406            return Err(ParseError::Validation(
407                "empty record name in `link<>`".to_string(),
408            ));
409        }
410        return Ok(ir::Ty::Link(name.to_string()));
411    }
412    // String literal: `'value'`.
413    if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
414        return Ok(ir::Ty::Literal(inner.to_string()));
415    }
416    let prim = match s {
417        "string" => Some(ir::Prim::String),
418        "int" => Some(ir::Prim::Int),
419        "float" | "number" => Some(ir::Prim::Float),
420        "bool" => Some(ir::Prim::Bool),
421        "datetime" | "timestamp" => Some(ir::Prim::Datetime),
422        "json" | "object" => Some(ir::Prim::Json),
423        _ => None,
424    };
425    match prim {
426        Some(p) => Ok(ir::Ty::Primitive(p)),
427        None if s.is_empty() => Err(ParseError::Validation("empty field type".to_string())),
428        None => Ok(ir::Ty::Named(s.to_string())),
429    }
430}
431
432/// Split a type string on top-level `|`, ignoring `|` nested inside `<...>`
433/// brackets or `'...'` string literals. Returns the trimmed segments.
434fn split_top_level_union(s: &str) -> Vec<String> {
435    let mut parts: Vec<String> = Vec::new();
436    let mut cur = String::new();
437    let mut depth: usize = 0;
438    let mut in_literal = false;
439    for c in s.chars() {
440        match c {
441            '\'' => {
442                in_literal = !in_literal;
443                cur.push(c);
444            }
445            '<' if !in_literal => {
446                depth += 1;
447                cur.push(c);
448            }
449            '>' if !in_literal => {
450                depth = depth.saturating_sub(1);
451                cur.push(c);
452            }
453            '|' if depth == 0 && !in_literal => {
454                parts.push(cur.trim().to_string());
455                cur.clear();
456            }
457            _ => cur.push(c),
458        }
459    }
460    parts.push(cur.trim().to_string());
461    parts
462}
463
464// =============================================================================
465// Tests
466// =============================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn parses_a_protocol_with_request_and_returns() {
474        let src = r#"
475            protocol "ping-pong" version="2.0.0" {
476                namespace "test.pp"
477                channel "pp" from="client" lifetime="persistent" {
478                    request "Ping" {
479                        field "message" type="string"
480                        returns "Pong" {
481                            field "reply" type="string"
482                        }
483                    }
484                }
485            }
486        "#;
487        let schema = parse(src).expect("should parse");
488        let protocol = schema.protocol.expect("has protocol");
489        assert_eq!(protocol.name, "ping-pong");
490        assert_eq!(protocol.version, "2.0.0");
491        assert_eq!(protocol.namespace.as_deref(), Some("test.pp"));
492        assert_eq!(protocol.channels.len(), 1);
493
494        let channel = &protocol.channels[0];
495        assert_eq!(channel.name, "pp");
496        assert_eq!(channel.from, ir::ChannelFrom::Client);
497        assert_eq!(channel.lifetime, ir::ChannelLifetime::Persistent);
498        assert_eq!(channel.backend, ir::ChannelBackend::Stream);
499        assert_eq!(channel.requests.len(), 1);
500
501        let request = &channel.requests[0];
502        assert_eq!(request.name, "Ping");
503        assert_eq!(
504            request.fields,
505            vec![ir::Field {
506                name: "message".to_string(),
507                ty: ir::Ty::Primitive(ir::Prim::String),
508                required: true,
509                flexible: false,
510                default: None,
511                description: None,
512                constraints: ir::Constraints::default(),
513            }]
514        );
515        let returns = request.returns.as_ref().expect("has returns");
516        assert_eq!(returns.name, "Pong");
517        assert_eq!(returns.fields[0].name, "reply");
518    }
519
520    #[test]
521    fn parses_events_and_optional_fields() {
522        let src = r#"
523            protocol "p" version="1.0.0" {
524                channel "c" from="server" lifetime="persistent" {
525                    event "Tick" {
526                        field "seq" type="int"
527                        field "note" type="string" optional=#true
528                    }
529                }
530            }
531        "#;
532        let schema = parse(src).expect("should parse");
533        let channel = &schema.protocol.unwrap().channels[0];
534        let event = &channel.events[0];
535        assert_eq!(event.name, "Tick");
536        assert!(
537            event.fields[0].required,
538            "unmarked field defaults to required"
539        );
540        assert!(
541            !event.fields[1].required,
542            "explicit optional=#true → not required"
543        );
544    }
545
546    #[test]
547    fn datagram_channel_requires_channel_id() {
548        let src = r#"
549            protocol "p" version="1.0.0" {
550                channel "metric" from="server" lifetime="persistent" backend="datagram" {
551                    event "M" { field "v" type="int" }
552                }
553            }
554        "#;
555        let err = parse(src).expect_err("datagram without channel_id is invalid");
556        assert!(matches!(err, ParseError::Validation(_)));
557    }
558
559    #[test]
560    fn datagram_channel_rejects_requests() {
561        let src = r#"
562            protocol "p" version="1.0.0" {
563                channel "c" from="client" lifetime="persistent" backend="datagram" channel_id=1 {
564                    request "R" { field "x" type="int" }
565                }
566            }
567        "#;
568        let err = parse(src).expect_err("datagram with request is invalid");
569        assert!(matches!(err, ParseError::Validation(_)));
570    }
571
572    #[test]
573    fn parses_datagram_channel_with_id() {
574        let src = r#"
575            protocol "p" version="1.0.0" {
576                channel "metric" from="server" lifetime="persistent" backend="datagram" channel_id=7 {
577                    event "M" { field "v" type="int" }
578                }
579            }
580        "#;
581        let channel = &parse(src).unwrap().protocol.unwrap().channels[0];
582        assert_eq!(channel.backend, ir::ChannelBackend::Datagram);
583        assert_eq!(channel.channel_id, Some(7));
584    }
585
586    #[test]
587    fn parses_data_dialect_struct_and_enum() {
588        let src = r#"
589            struct "User" {
590                field "id" type="string"
591                field "tags" type="array<string>"
592                field "role" type="Role"
593            }
594            enum "Role" {
595                variant "admin"
596                variant "member"
597            }
598        "#;
599        let schema = parse(src).expect("should parse");
600        assert!(schema.protocol.is_none());
601        assert_eq!(schema.types.len(), 2);
602
603        match &schema.types[0] {
604            ir::TypeDef::Struct { name, fields, .. } => {
605                assert_eq!(name, "User");
606                assert_eq!(
607                    fields[1].ty,
608                    ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
609                );
610                assert_eq!(fields[2].ty, ir::Ty::Named("Role".to_string()));
611            }
612            other => panic!("expected struct, got {other:?}"),
613        }
614        match &schema.types[1] {
615            ir::TypeDef::Enum { name, variants, .. } => {
616                assert_eq!(name, "Role");
617                assert_eq!(variants, &["admin", "member"]);
618            }
619            other => panic!("expected enum, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn rejects_unknown_channel_from() {
625        let src = r#"
626            protocol "p" version="1.0.0" {
627                channel "c" from="nobody" lifetime="persistent" {
628                    event "E" { field "x" type="int" }
629                }
630            }
631        "#;
632        let err = parse(src).expect_err("unknown from value is invalid");
633        assert!(matches!(err, ParseError::Validation(_)));
634    }
635
636    #[test]
637    fn primitive_type_aliases() {
638        assert_eq!(
639            parse_ty("object").unwrap(),
640            ir::Ty::Primitive(ir::Prim::Json)
641        );
642        assert_eq!(
643            parse_ty("number").unwrap(),
644            ir::Ty::Primitive(ir::Prim::Float)
645        );
646        assert_eq!(
647            parse_ty("timestamp").unwrap(),
648            ir::Ty::Primitive(ir::Prim::Datetime)
649        );
650    }
651
652    #[test]
653    fn rejects_unknown_type_reference() {
654        let src = r#"
655            struct "User" {
656                field "role" type="Role"
657            }
658        "#;
659        // `Role` is never defined as a struct/enum.
660        let err = parse(src).expect_err("unknown type reference is invalid");
661        assert!(matches!(err, ParseError::Validation(_)));
662    }
663
664    #[test]
665    fn accepts_array_of_defined_type() {
666        let src = r#"
667            struct "Team" {
668                field "members" type="array<User>"
669            }
670            struct "User" {
671                field "id" type="string"
672            }
673        "#;
674        // `array<User>` resolves because `User` is defined; order-independent.
675        parse(src).expect("array<User> with User defined should parse");
676    }
677
678    // -------------------------------------------------------------------------
679    // Tier 1 — record / relation / link / union
680    // -------------------------------------------------------------------------
681
682    #[test]
683    fn parses_record_with_id_strategy_and_fields() {
684        let src = r#"
685            record "Atlas" {
686                id strategy="uuidv7"
687                field "name"   type="string"
688                field "parent" type="link<Atlas>"
689            }
690        "#;
691        let schema = parse(src).expect("record should parse");
692        assert_eq!(schema.records.len(), 1);
693        let atlas = &schema.records[0];
694        assert_eq!(atlas.name, "Atlas");
695        assert_eq!(atlas.id_strategy, ir::IdStrategy::Uuidv7);
696        assert_eq!(atlas.fields[0].name, "name");
697        // self-link resolves because `Atlas` is itself a defined record.
698        assert_eq!(atlas.fields[1].ty, ir::Ty::Link("Atlas".to_string()));
699    }
700
701    #[test]
702    fn record_id_strategy_defaults_to_uuidv7_when_absent() {
703        let src = r#"
704            record "Note" {
705                field "body" type="string"
706            }
707        "#;
708        let schema = parse(src).expect("record without `id` node should parse");
709        assert_eq!(schema.records[0].id_strategy, ir::IdStrategy::Uuidv7);
710    }
711
712    #[test]
713    fn parses_all_id_strategies() {
714        for (kw, expected) in [
715            ("ulid", ir::IdStrategy::Ulid),
716            ("manual", ir::IdStrategy::Manual),
717            ("uuidv7", ir::IdStrategy::Uuidv7),
718        ] {
719            let src = format!(
720                r#"record "R" {{ id strategy="{kw}"
721                       field "x" type="string" }}"#
722            );
723            let schema = parse(&src).expect("record parses");
724            assert_eq!(schema.records[0].id_strategy, expected);
725        }
726    }
727
728    #[test]
729    fn rejects_unknown_id_strategy() {
730        let src = r#"record "R" { id strategy="snowflake"
731                       field "x" type="string" }"#;
732        let err = parse(src).expect_err("unknown id strategy is invalid");
733        assert!(matches!(err, ParseError::Validation(_)));
734    }
735
736    #[test]
737    fn parses_relation_with_endpoints_and_edge_fields() {
738        let src = r#"
739            record "Memory" {
740                field "body" type="string"
741            }
742            relation "derivedFrom" from="Memory" to="Memory" unique=#true {
743                field "confidence" type="float"
744                field "reason"     type="string"
745            }
746        "#;
747        let schema = parse(src).expect("relation should parse");
748        assert_eq!(schema.relations.len(), 1);
749        let rel = &schema.relations[0];
750        assert_eq!(rel.name, "derivedFrom");
751        assert_eq!(rel.from, "Memory");
752        assert_eq!(rel.to, "Memory");
753        assert!(rel.unique);
754        assert_eq!(rel.fields.len(), 2);
755        assert_eq!(rel.fields[0].name, "confidence");
756    }
757
758    #[test]
759    fn relation_unique_defaults_to_false() {
760        let src = r#"
761            record "A" { field "x" type="string" }
762            relation "rel" from="A" to="A"
763        "#;
764        let schema = parse(src).expect("relation parses");
765        assert!(!schema.relations[0].unique);
766    }
767
768    #[test]
769    fn rejects_relation_with_unknown_endpoint() {
770        let src = r#"
771            record "A" { field "x" type="string" }
772            relation "rel" from="A" to="Ghost"
773        "#;
774        let err = parse(src).expect_err("unknown relation endpoint is invalid");
775        assert!(matches!(err, ParseError::Validation(_)));
776    }
777
778    #[test]
779    fn parse_ty_link_and_literal_and_union() {
780        assert_eq!(
781            parse_ty("link<Atlas>").unwrap(),
782            ir::Ty::Link("Atlas".to_string())
783        );
784        assert_eq!(
785            parse_ty("'public'").unwrap(),
786            ir::Ty::Literal("public".to_string())
787        );
788        assert_eq!(
789            parse_ty("'public' | 'private'").unwrap(),
790            ir::Ty::Union(vec![
791                ir::Ty::Literal("public".to_string()),
792                ir::Ty::Literal("private".to_string()),
793            ])
794        );
795        assert_eq!(
796            parse_ty("string | int | bool").unwrap(),
797            ir::Ty::Union(vec![
798                ir::Ty::Primitive(ir::Prim::String),
799                ir::Ty::Primitive(ir::Prim::Int),
800                ir::Ty::Primitive(ir::Prim::Bool),
801            ])
802        );
803    }
804
805    #[test]
806    fn union_does_not_split_inside_brackets() {
807        // `|` inside `array<...>` must not be treated as a union separator.
808        // (there is no nested union here, but the splitter must stay depth-aware)
809        assert_eq!(
810            parse_ty("array<string>").unwrap(),
811            ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
812        );
813    }
814
815    #[test]
816    fn link_to_unknown_record_is_rejected() {
817        let src = r#"
818            record "Atlas" {
819                field "parent" type="link<Ghost>"
820            }
821        "#;
822        let err = parse(src).expect_err("link to undefined record is invalid");
823        assert!(matches!(err, ParseError::Validation(_)));
824    }
825
826    #[test]
827    fn flexible_and_default_properties_are_lowered() {
828        let src = r#"
829            record "Atlas" {
830                field "metadata"   type="object" flexible=#true
831                field "visibility" type="'public' | 'private'" default="private"
832            }
833        "#;
834        let schema = parse(src).expect("record should parse");
835        let fields = &schema.records[0].fields;
836        assert!(fields[0].flexible, "flexible=#true is lowered");
837        assert!(!fields[1].flexible, "absent flexible defaults false");
838        assert_eq!(fields[1].default.as_deref(), Some("private"));
839    }
840
841    #[test]
842    fn bare_name_is_embedded_link_is_stored() {
843        // A bare `Name` resolves to a struct/enum; `link<Name>` to a record.
844        // The same identifier in both forms must keep its distinct meaning.
845        let src = r#"
846            struct "GeoPoint" {
847                field "lat" type="float"
848            }
849            record "Place" {
850                field "at"     type="GeoPoint"
851                field "parent" type="link<Place>"
852            }
853        "#;
854        let schema = parse(src).expect("schema parses");
855        let fields = &schema.records[0].fields;
856        assert_eq!(fields[0].ty, ir::Ty::Named("GeoPoint".to_string()));
857        assert_eq!(fields[1].ty, ir::Ty::Link("Place".to_string()));
858    }
859
860    // -------------------------------------------------------------------------
861    // Tier 2 — description / constraints
862    // -------------------------------------------------------------------------
863
864    #[test]
865    fn record_and_field_descriptions_are_lowered() {
866        let src = r#"
867            record "Memory" description="User memory with content" {
868                field "content" type="string" description="Memory content text"
869            }
870        "#;
871        let schema = parse(src).expect("record with descriptions parses");
872        let memory = &schema.records[0];
873        assert_eq!(
874            memory.description.as_deref(),
875            Some("User memory with content")
876        );
877        assert_eq!(
878            memory.fields[0].description.as_deref(),
879            Some("Memory content text")
880        );
881    }
882
883    #[test]
884    fn struct_enum_relation_descriptions_are_lowered() {
885        let src = r#"
886            struct "Point" description="A 2D point" {
887                field "x" type="float"
888            }
889            enum "Color" description="An RGB primary" {
890                variant "red"
891            }
892            record "Node" { field "v" type="int" }
893            relation "edge" from="Node" to="Node" description="A directed edge"
894        "#;
895        let schema = parse(src).expect("schema parses");
896        match &schema.types[0] {
897            ir::TypeDef::Struct { description, .. } => {
898                assert_eq!(description.as_deref(), Some("A 2D point"));
899            }
900            other => panic!("expected struct, got {other:?}"),
901        }
902        match &schema.types[1] {
903            ir::TypeDef::Enum { description, .. } => {
904                assert_eq!(description.as_deref(), Some("An RGB primary"));
905            }
906            other => panic!("expected enum, got {other:?}"),
907        }
908        assert_eq!(
909            schema.relations[0].description.as_deref(),
910            Some("A directed edge")
911        );
912    }
913
914    #[test]
915    fn field_constraints_are_lowered() {
916        let src = r#"
917            struct "Profile" {
918                field "confidence" type="float" min=0 max=1
919                field "name"       type="string" min_length=1 max_length=32 pattern="^[a-z]+$"
920            }
921        "#;
922        let schema = parse(src).expect("schema parses");
923        let fields = match &schema.types[0] {
924            ir::TypeDef::Struct { fields, .. } => fields,
925            other => panic!("expected struct, got {other:?}"),
926        };
927        assert_eq!(fields[0].constraints.min, Some(0));
928        assert_eq!(fields[0].constraints.max, Some(1));
929        assert_eq!(fields[1].constraints.min_length, Some(1));
930        assert_eq!(fields[1].constraints.max_length, Some(32));
931        assert_eq!(fields[1].constraints.pattern.as_deref(), Some("^[a-z]+$"));
932    }
933
934    #[test]
935    fn absent_constraints_and_description_default_to_none() {
936        let src = r#"
937            struct "Bare" {
938                field "x" type="int"
939            }
940        "#;
941        let schema = parse(src).expect("schema parses");
942        let fields = match &schema.types[0] {
943            ir::TypeDef::Struct { fields, .. } => fields,
944            other => panic!("expected struct, got {other:?}"),
945        };
946        assert!(fields[0].description.is_none());
947        assert!(
948            fields[0].constraints.is_empty(),
949            "a field with no constraint properties has empty Constraints"
950        );
951    }
952}