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        requests,
280        events,
281    })
282}
283
284fn lower_request(raw: raw::RawRequest) -> Result<ir::Request, ParseError> {
285    Ok(ir::Request {
286        name: raw.name,
287        fields: lower_fields(raw.fields)?,
288        returns: raw.returns.map(lower_message).transpose()?,
289    })
290}
291
292fn lower_event(raw: raw::RawEvent) -> Result<ir::Event, ParseError> {
293    Ok(ir::Event {
294        name: raw.name,
295        fields: lower_fields(raw.fields)?,
296    })
297}
298
299fn lower_message(raw: raw::RawMessage) -> Result<ir::Message, ParseError> {
300    Ok(ir::Message {
301        name: raw.name,
302        fields: lower_fields(raw.fields)?,
303    })
304}
305
306fn lower_fields(raw: Vec<raw::RawField>) -> Result<Vec<ir::Field>, ParseError> {
307    raw.into_iter().map(lower_field).collect()
308}
309
310fn lower_field(raw: raw::RawField) -> Result<ir::Field, ParseError> {
311    Ok(ir::Field {
312        ty: parse_ty(&raw.type_str)?,
313        name: raw.name,
314        required: !raw.optional,
315        flexible: raw.flexible,
316        default: raw.default,
317        description: raw.description,
318        constraints: ir::Constraints {
319            min: raw.min,
320            max: raw.max,
321            min_length: raw.min_length,
322            max_length: raw.max_length,
323            pattern: raw.pattern,
324        },
325    })
326}
327
328// =============================================================================
329// Scalar lowering helpers
330// =============================================================================
331
332fn lower_channel_from(s: &str) -> Result<ir::ChannelFrom, ParseError> {
333    match s {
334        "client" => Ok(ir::ChannelFrom::Client),
335        "server" => Ok(ir::ChannelFrom::Server),
336        "either" => Ok(ir::ChannelFrom::Either),
337        other => Err(ParseError::Validation(format!(
338            "unknown channel `from` value {other:?} (expected client/server/either)"
339        ))),
340    }
341}
342
343fn lower_channel_lifetime(s: &str) -> Result<ir::ChannelLifetime, ParseError> {
344    match s {
345        "transient" => Ok(ir::ChannelLifetime::Transient),
346        "persistent" => Ok(ir::ChannelLifetime::Persistent),
347        other => Err(ParseError::Validation(format!(
348            "unknown channel `lifetime` value {other:?} (expected transient/persistent)"
349        ))),
350    }
351}
352
353fn lower_channel_backend(s: Option<&str>) -> Result<ir::ChannelBackend, ParseError> {
354    match s {
355        None | Some("stream") => Ok(ir::ChannelBackend::Stream),
356        Some("datagram") => Ok(ir::ChannelBackend::Datagram),
357        Some(other) => Err(ParseError::Validation(format!(
358            "unknown channel `backend` value {other:?} (expected stream/datagram)"
359        ))),
360    }
361}
362
363/// Parse a field-type string into a [`Ty`](ir::Ty).
364///
365/// Grammar (lowest precedence first):
366///
367/// - `A | B | ...` — a [`Ty::Union`](ir::Ty::Union). `|` is split only at
368///   the top level (not inside `array<...>` / `link<...>`).
369/// - `array<T>` — a [`Ty::Array`](ir::Ty::Array).
370/// - `link<Name>` — a [`Ty::Link`](ir::Ty::Link), a stored record reference.
371/// - `'literal'` — a [`Ty::Literal`](ir::Ty::Literal) (single-quoted).
372/// - the primitive set — `string` / `int` / `float` / `bool` / `datetime` /
373///   `json`. `object` aliases `json`, `number` aliases `float`, `timestamp`
374///   aliases `datetime`.
375/// - any other identifier — a [`Ty::Named`](ir::Ty::Named) reference to a
376///   `struct` / `enum`.
377fn parse_ty(s: &str) -> Result<ir::Ty, ParseError> {
378    let s = s.trim();
379    if s.is_empty() {
380        return Err(ParseError::Validation("empty field type".to_string()));
381    }
382
383    // Union has the lowest precedence — split on top-level `|`.
384    let members = split_top_level_union(s);
385    if members.len() > 1 {
386        let parsed = members
387            .iter()
388            .map(|m| parse_ty(m))
389            .collect::<Result<Vec<_>, _>>()?;
390        return Ok(ir::Ty::Union(parsed));
391    }
392
393    parse_atom(s)
394}
395
396/// Parse a single (non-union) type atom.
397fn parse_atom(s: &str) -> Result<ir::Ty, ParseError> {
398    let s = s.trim();
399    if let Some(inner) = s.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
400        return Ok(ir::Ty::Array(Box::new(parse_ty(inner)?)));
401    }
402    if let Some(inner) = s.strip_prefix("link<").and_then(|r| r.strip_suffix('>')) {
403        let name = inner.trim();
404        if name.is_empty() {
405            return Err(ParseError::Validation(
406                "empty record name in `link<>`".to_string(),
407            ));
408        }
409        return Ok(ir::Ty::Link(name.to_string()));
410    }
411    // String literal: `'value'`.
412    if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
413        return Ok(ir::Ty::Literal(inner.to_string()));
414    }
415    let prim = match s {
416        "string" => Some(ir::Prim::String),
417        "int" => Some(ir::Prim::Int),
418        "float" | "number" => Some(ir::Prim::Float),
419        "bool" => Some(ir::Prim::Bool),
420        "datetime" | "timestamp" => Some(ir::Prim::Datetime),
421        "json" | "object" => Some(ir::Prim::Json),
422        _ => None,
423    };
424    match prim {
425        Some(p) => Ok(ir::Ty::Primitive(p)),
426        None if s.is_empty() => Err(ParseError::Validation("empty field type".to_string())),
427        None => Ok(ir::Ty::Named(s.to_string())),
428    }
429}
430
431/// Split a type string on top-level `|`, ignoring `|` nested inside `<...>`
432/// brackets or `'...'` string literals. Returns the trimmed segments.
433fn split_top_level_union(s: &str) -> Vec<String> {
434    let mut parts: Vec<String> = Vec::new();
435    let mut cur = String::new();
436    let mut depth: usize = 0;
437    let mut in_literal = false;
438    for c in s.chars() {
439        match c {
440            '\'' => {
441                in_literal = !in_literal;
442                cur.push(c);
443            }
444            '<' if !in_literal => {
445                depth += 1;
446                cur.push(c);
447            }
448            '>' if !in_literal => {
449                depth = depth.saturating_sub(1);
450                cur.push(c);
451            }
452            '|' if depth == 0 && !in_literal => {
453                parts.push(cur.trim().to_string());
454                cur.clear();
455            }
456            _ => cur.push(c),
457        }
458    }
459    parts.push(cur.trim().to_string());
460    parts
461}
462
463// =============================================================================
464// Tests
465// =============================================================================
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn parses_a_protocol_with_request_and_returns() {
473        let src = r#"
474            protocol "ping-pong" version="2.0.0" {
475                namespace "test.pp"
476                channel "pp" from="client" lifetime="persistent" {
477                    request "Ping" {
478                        field "message" type="string"
479                        returns "Pong" {
480                            field "reply" type="string"
481                        }
482                    }
483                }
484            }
485        "#;
486        let schema = parse(src).expect("should parse");
487        let protocol = schema.protocol.expect("has protocol");
488        assert_eq!(protocol.name, "ping-pong");
489        assert_eq!(protocol.version, "2.0.0");
490        assert_eq!(protocol.namespace.as_deref(), Some("test.pp"));
491        assert_eq!(protocol.channels.len(), 1);
492
493        let channel = &protocol.channels[0];
494        assert_eq!(channel.name, "pp");
495        assert_eq!(channel.from, ir::ChannelFrom::Client);
496        assert_eq!(channel.lifetime, ir::ChannelLifetime::Persistent);
497        assert_eq!(channel.backend, ir::ChannelBackend::Stream);
498        assert_eq!(channel.requests.len(), 1);
499
500        let request = &channel.requests[0];
501        assert_eq!(request.name, "Ping");
502        assert_eq!(
503            request.fields,
504            vec![ir::Field {
505                name: "message".to_string(),
506                ty: ir::Ty::Primitive(ir::Prim::String),
507                required: true,
508                flexible: false,
509                default: None,
510                description: None,
511                constraints: ir::Constraints::default(),
512            }]
513        );
514        let returns = request.returns.as_ref().expect("has returns");
515        assert_eq!(returns.name, "Pong");
516        assert_eq!(returns.fields[0].name, "reply");
517    }
518
519    #[test]
520    fn parses_events_and_optional_fields() {
521        let src = r#"
522            protocol "p" version="1.0.0" {
523                channel "c" from="server" lifetime="persistent" {
524                    event "Tick" {
525                        field "seq" type="int"
526                        field "note" type="string" optional=#true
527                    }
528                }
529            }
530        "#;
531        let schema = parse(src).expect("should parse");
532        let channel = &schema.protocol.unwrap().channels[0];
533        let event = &channel.events[0];
534        assert_eq!(event.name, "Tick");
535        assert!(
536            event.fields[0].required,
537            "unmarked field defaults to required"
538        );
539        assert!(
540            !event.fields[1].required,
541            "explicit optional=#true → not required"
542        );
543    }
544
545    #[test]
546    fn datagram_channel_requires_channel_id() {
547        let src = r#"
548            protocol "p" version="1.0.0" {
549                channel "metric" from="server" lifetime="persistent" backend="datagram" {
550                    event "M" { field "v" type="int" }
551                }
552            }
553        "#;
554        let err = parse(src).expect_err("datagram without channel_id is invalid");
555        assert!(matches!(err, ParseError::Validation(_)));
556    }
557
558    #[test]
559    fn datagram_channel_rejects_requests() {
560        let src = r#"
561            protocol "p" version="1.0.0" {
562                channel "c" from="client" lifetime="persistent" backend="datagram" channel_id=1 {
563                    request "R" { field "x" type="int" }
564                }
565            }
566        "#;
567        let err = parse(src).expect_err("datagram with request is invalid");
568        assert!(matches!(err, ParseError::Validation(_)));
569    }
570
571    #[test]
572    fn parses_datagram_channel_with_id() {
573        let src = r#"
574            protocol "p" version="1.0.0" {
575                channel "metric" from="server" lifetime="persistent" backend="datagram" channel_id=7 {
576                    event "M" { field "v" type="int" }
577                }
578            }
579        "#;
580        let channel = &parse(src).unwrap().protocol.unwrap().channels[0];
581        assert_eq!(channel.backend, ir::ChannelBackend::Datagram);
582        assert_eq!(channel.channel_id, Some(7));
583    }
584
585    #[test]
586    fn parses_data_dialect_struct_and_enum() {
587        let src = r#"
588            struct "User" {
589                field "id" type="string"
590                field "tags" type="array<string>"
591                field "role" type="Role"
592            }
593            enum "Role" {
594                variant "admin"
595                variant "member"
596            }
597        "#;
598        let schema = parse(src).expect("should parse");
599        assert!(schema.protocol.is_none());
600        assert_eq!(schema.types.len(), 2);
601
602        match &schema.types[0] {
603            ir::TypeDef::Struct { name, fields, .. } => {
604                assert_eq!(name, "User");
605                assert_eq!(
606                    fields[1].ty,
607                    ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
608                );
609                assert_eq!(fields[2].ty, ir::Ty::Named("Role".to_string()));
610            }
611            other => panic!("expected struct, got {other:?}"),
612        }
613        match &schema.types[1] {
614            ir::TypeDef::Enum { name, variants, .. } => {
615                assert_eq!(name, "Role");
616                assert_eq!(variants, &["admin", "member"]);
617            }
618            other => panic!("expected enum, got {other:?}"),
619        }
620    }
621
622    #[test]
623    fn rejects_unknown_channel_from() {
624        let src = r#"
625            protocol "p" version="1.0.0" {
626                channel "c" from="nobody" lifetime="persistent" {
627                    event "E" { field "x" type="int" }
628                }
629            }
630        "#;
631        let err = parse(src).expect_err("unknown from value is invalid");
632        assert!(matches!(err, ParseError::Validation(_)));
633    }
634
635    #[test]
636    fn primitive_type_aliases() {
637        assert_eq!(
638            parse_ty("object").unwrap(),
639            ir::Ty::Primitive(ir::Prim::Json)
640        );
641        assert_eq!(
642            parse_ty("number").unwrap(),
643            ir::Ty::Primitive(ir::Prim::Float)
644        );
645        assert_eq!(
646            parse_ty("timestamp").unwrap(),
647            ir::Ty::Primitive(ir::Prim::Datetime)
648        );
649    }
650
651    #[test]
652    fn rejects_unknown_type_reference() {
653        let src = r#"
654            struct "User" {
655                field "role" type="Role"
656            }
657        "#;
658        // `Role` is never defined as a struct/enum.
659        let err = parse(src).expect_err("unknown type reference is invalid");
660        assert!(matches!(err, ParseError::Validation(_)));
661    }
662
663    #[test]
664    fn accepts_array_of_defined_type() {
665        let src = r#"
666            struct "Team" {
667                field "members" type="array<User>"
668            }
669            struct "User" {
670                field "id" type="string"
671            }
672        "#;
673        // `array<User>` resolves because `User` is defined; order-independent.
674        parse(src).expect("array<User> with User defined should parse");
675    }
676
677    // -------------------------------------------------------------------------
678    // Tier 1 — record / relation / link / union
679    // -------------------------------------------------------------------------
680
681    #[test]
682    fn parses_record_with_id_strategy_and_fields() {
683        let src = r#"
684            record "Atlas" {
685                id strategy="uuidv7"
686                field "name"   type="string"
687                field "parent" type="link<Atlas>"
688            }
689        "#;
690        let schema = parse(src).expect("record should parse");
691        assert_eq!(schema.records.len(), 1);
692        let atlas = &schema.records[0];
693        assert_eq!(atlas.name, "Atlas");
694        assert_eq!(atlas.id_strategy, ir::IdStrategy::Uuidv7);
695        assert_eq!(atlas.fields[0].name, "name");
696        // self-link resolves because `Atlas` is itself a defined record.
697        assert_eq!(atlas.fields[1].ty, ir::Ty::Link("Atlas".to_string()));
698    }
699
700    #[test]
701    fn record_id_strategy_defaults_to_uuidv7_when_absent() {
702        let src = r#"
703            record "Note" {
704                field "body" type="string"
705            }
706        "#;
707        let schema = parse(src).expect("record without `id` node should parse");
708        assert_eq!(schema.records[0].id_strategy, ir::IdStrategy::Uuidv7);
709    }
710
711    #[test]
712    fn parses_all_id_strategies() {
713        for (kw, expected) in [
714            ("ulid", ir::IdStrategy::Ulid),
715            ("manual", ir::IdStrategy::Manual),
716            ("uuidv7", ir::IdStrategy::Uuidv7),
717        ] {
718            let src = format!(
719                r#"record "R" {{ id strategy="{kw}"
720                       field "x" type="string" }}"#
721            );
722            let schema = parse(&src).expect("record parses");
723            assert_eq!(schema.records[0].id_strategy, expected);
724        }
725    }
726
727    #[test]
728    fn rejects_unknown_id_strategy() {
729        let src = r#"record "R" { id strategy="snowflake"
730                       field "x" type="string" }"#;
731        let err = parse(src).expect_err("unknown id strategy is invalid");
732        assert!(matches!(err, ParseError::Validation(_)));
733    }
734
735    #[test]
736    fn parses_relation_with_endpoints_and_edge_fields() {
737        let src = r#"
738            record "Memory" {
739                field "body" type="string"
740            }
741            relation "derivedFrom" from="Memory" to="Memory" unique=#true {
742                field "confidence" type="float"
743                field "reason"     type="string"
744            }
745        "#;
746        let schema = parse(src).expect("relation should parse");
747        assert_eq!(schema.relations.len(), 1);
748        let rel = &schema.relations[0];
749        assert_eq!(rel.name, "derivedFrom");
750        assert_eq!(rel.from, "Memory");
751        assert_eq!(rel.to, "Memory");
752        assert!(rel.unique);
753        assert_eq!(rel.fields.len(), 2);
754        assert_eq!(rel.fields[0].name, "confidence");
755    }
756
757    #[test]
758    fn relation_unique_defaults_to_false() {
759        let src = r#"
760            record "A" { field "x" type="string" }
761            relation "rel" from="A" to="A"
762        "#;
763        let schema = parse(src).expect("relation parses");
764        assert!(!schema.relations[0].unique);
765    }
766
767    #[test]
768    fn rejects_relation_with_unknown_endpoint() {
769        let src = r#"
770            record "A" { field "x" type="string" }
771            relation "rel" from="A" to="Ghost"
772        "#;
773        let err = parse(src).expect_err("unknown relation endpoint is invalid");
774        assert!(matches!(err, ParseError::Validation(_)));
775    }
776
777    #[test]
778    fn parse_ty_link_and_literal_and_union() {
779        assert_eq!(
780            parse_ty("link<Atlas>").unwrap(),
781            ir::Ty::Link("Atlas".to_string())
782        );
783        assert_eq!(
784            parse_ty("'public'").unwrap(),
785            ir::Ty::Literal("public".to_string())
786        );
787        assert_eq!(
788            parse_ty("'public' | 'private'").unwrap(),
789            ir::Ty::Union(vec![
790                ir::Ty::Literal("public".to_string()),
791                ir::Ty::Literal("private".to_string()),
792            ])
793        );
794        assert_eq!(
795            parse_ty("string | int | bool").unwrap(),
796            ir::Ty::Union(vec![
797                ir::Ty::Primitive(ir::Prim::String),
798                ir::Ty::Primitive(ir::Prim::Int),
799                ir::Ty::Primitive(ir::Prim::Bool),
800            ])
801        );
802    }
803
804    #[test]
805    fn union_does_not_split_inside_brackets() {
806        // `|` inside `array<...>` must not be treated as a union separator.
807        // (there is no nested union here, but the splitter must stay depth-aware)
808        assert_eq!(
809            parse_ty("array<string>").unwrap(),
810            ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
811        );
812    }
813
814    #[test]
815    fn link_to_unknown_record_is_rejected() {
816        let src = r#"
817            record "Atlas" {
818                field "parent" type="link<Ghost>"
819            }
820        "#;
821        let err = parse(src).expect_err("link to undefined record is invalid");
822        assert!(matches!(err, ParseError::Validation(_)));
823    }
824
825    #[test]
826    fn flexible_and_default_properties_are_lowered() {
827        let src = r#"
828            record "Atlas" {
829                field "metadata"   type="object" flexible=#true
830                field "visibility" type="'public' | 'private'" default="private"
831            }
832        "#;
833        let schema = parse(src).expect("record should parse");
834        let fields = &schema.records[0].fields;
835        assert!(fields[0].flexible, "flexible=#true is lowered");
836        assert!(!fields[1].flexible, "absent flexible defaults false");
837        assert_eq!(fields[1].default.as_deref(), Some("private"));
838    }
839
840    #[test]
841    fn bare_name_is_embedded_link_is_stored() {
842        // A bare `Name` resolves to a struct/enum; `link<Name>` to a record.
843        // The same identifier in both forms must keep its distinct meaning.
844        let src = r#"
845            struct "GeoPoint" {
846                field "lat" type="float"
847            }
848            record "Place" {
849                field "at"     type="GeoPoint"
850                field "parent" type="link<Place>"
851            }
852        "#;
853        let schema = parse(src).expect("schema parses");
854        let fields = &schema.records[0].fields;
855        assert_eq!(fields[0].ty, ir::Ty::Named("GeoPoint".to_string()));
856        assert_eq!(fields[1].ty, ir::Ty::Link("Place".to_string()));
857    }
858
859    // -------------------------------------------------------------------------
860    // Tier 2 — description / constraints
861    // -------------------------------------------------------------------------
862
863    #[test]
864    fn record_and_field_descriptions_are_lowered() {
865        let src = r#"
866            record "Memory" description="User memory with content" {
867                field "content" type="string" description="Memory content text"
868            }
869        "#;
870        let schema = parse(src).expect("record with descriptions parses");
871        let memory = &schema.records[0];
872        assert_eq!(
873            memory.description.as_deref(),
874            Some("User memory with content")
875        );
876        assert_eq!(
877            memory.fields[0].description.as_deref(),
878            Some("Memory content text")
879        );
880    }
881
882    #[test]
883    fn struct_enum_relation_descriptions_are_lowered() {
884        let src = r#"
885            struct "Point" description="A 2D point" {
886                field "x" type="float"
887            }
888            enum "Color" description="An RGB primary" {
889                variant "red"
890            }
891            record "Node" { field "v" type="int" }
892            relation "edge" from="Node" to="Node" description="A directed edge"
893        "#;
894        let schema = parse(src).expect("schema parses");
895        match &schema.types[0] {
896            ir::TypeDef::Struct { description, .. } => {
897                assert_eq!(description.as_deref(), Some("A 2D point"));
898            }
899            other => panic!("expected struct, got {other:?}"),
900        }
901        match &schema.types[1] {
902            ir::TypeDef::Enum { description, .. } => {
903                assert_eq!(description.as_deref(), Some("An RGB primary"));
904            }
905            other => panic!("expected enum, got {other:?}"),
906        }
907        assert_eq!(
908            schema.relations[0].description.as_deref(),
909            Some("A directed edge")
910        );
911    }
912
913    #[test]
914    fn field_constraints_are_lowered() {
915        let src = r#"
916            struct "Profile" {
917                field "confidence" type="float" min=0 max=1
918                field "name"       type="string" min_length=1 max_length=32 pattern="^[a-z]+$"
919            }
920        "#;
921        let schema = parse(src).expect("schema parses");
922        let fields = match &schema.types[0] {
923            ir::TypeDef::Struct { fields, .. } => fields,
924            other => panic!("expected struct, got {other:?}"),
925        };
926        assert_eq!(fields[0].constraints.min, Some(0));
927        assert_eq!(fields[0].constraints.max, Some(1));
928        assert_eq!(fields[1].constraints.min_length, Some(1));
929        assert_eq!(fields[1].constraints.max_length, Some(32));
930        assert_eq!(fields[1].constraints.pattern.as_deref(), Some("^[a-z]+$"));
931    }
932
933    #[test]
934    fn absent_constraints_and_description_default_to_none() {
935        let src = r#"
936            struct "Bare" {
937                field "x" type="int"
938            }
939        "#;
940        let schema = parse(src).expect("schema parses");
941        let fields = match &schema.types[0] {
942            ir::TypeDef::Struct { fields, .. } => fields,
943            other => panic!("expected struct, got {other:?}"),
944        };
945        assert!(fields[0].description.is_none());
946        assert!(
947            fields[0].constraints.is_empty(),
948            "a field with no constraint properties has empty Constraints"
949        );
950    }
951}