1mod raw;
18
19use std::collections::HashSet;
20use std::path::Path;
21
22use crate::ir;
23
24#[derive(Debug, thiserror::Error)]
30#[non_exhaustive]
31pub enum ParseError {
32 #[error("KDL parse error: {0}")]
34 Kdl(String),
35 #[error("schema validation error: {0}")]
37 Validation(String),
38 #[error("compose error: {0}")]
42 Compose(#[from] kdl_compose::ComposeError),
43}
44
45pub fn parse(src: &str) -> Result<ir::Schema, ParseError> {
54 let raw: raw::RawSchema =
55 club_kdl::from_str(src).map_err(|e| ParseError::Kdl(e.to_string()))?;
56 lower_schema(raw)
57}
58
59pub fn parse_doc(doc: &kdl::KdlDocument) -> Result<ir::Schema, ParseError> {
72 let raw: raw::RawSchema =
73 club_kdl::from_doc(doc).map_err(|e| ParseError::Kdl(e.to_string()))?;
74 lower_schema(raw)
75}
76
77pub fn parse_path(path: impl AsRef<Path>) -> Result<ir::Schema, ParseError> {
92 let doc = kdl_compose::compose(path)?;
93 parse_doc(&doc)
94}
95
96fn lower_schema(raw: raw::RawSchema) -> Result<ir::Schema, ParseError> {
101 let mut types = Vec::with_capacity(raw.structs.len() + raw.enums.len());
102 for s in raw.structs {
103 types.push(lower_struct(s)?);
104 }
105 for e in raw.enums {
106 types.push(lower_enum(e));
107 }
108 let records = raw
109 .records
110 .into_iter()
111 .map(lower_record)
112 .collect::<Result<Vec<_>, _>>()?;
113 let relations = raw
114 .relations
115 .into_iter()
116 .map(lower_relation)
117 .collect::<Result<Vec<_>, _>>()?;
118 let protocol = raw.protocol.map(lower_protocol).transpose()?;
119 let schema = ir::Schema {
120 types,
121 records,
122 relations,
123 protocol,
124 };
125 validate_type_refs(&schema)?;
126 Ok(schema)
127}
128
129struct DefinedNames<'a> {
134 values: HashSet<&'a str>,
136 records: HashSet<&'a str>,
138}
139
140fn validate_type_refs(schema: &ir::Schema) -> Result<(), ParseError> {
145 let values: HashSet<&str> = schema
146 .types
147 .iter()
148 .map(|t| match t {
149 ir::TypeDef::Struct { name, .. } | ir::TypeDef::Enum { name, .. } => name.as_str(),
150 })
151 .collect();
152 let records: HashSet<&str> = schema.records.iter().map(|r| r.name.as_str()).collect();
153 let defined = DefinedNames { values, records };
154
155 for ty in &schema.types {
156 if let ir::TypeDef::Struct { fields, .. } = ty {
157 check_fields(fields, &defined)?;
158 }
159 }
160 for record in &schema.records {
161 check_fields(&record.fields, &defined)?;
162 }
163 for relation in &schema.relations {
164 check_fields(&relation.fields, &defined)?;
165 for (role, endpoint) in [("from", &relation.from), ("to", &relation.to)] {
167 if !defined.records.contains(endpoint.as_str()) {
168 return Err(ParseError::Validation(format!(
169 "relation {:?} {role}={endpoint:?} references unknown record; \
170 define it as a `record`",
171 relation.name
172 )));
173 }
174 }
175 }
176 if let Some(protocol) = &schema.protocol {
177 for channel in &protocol.channels {
178 for request in &channel.requests {
179 check_fields(&request.fields, &defined)?;
180 if let Some(returns) = &request.returns {
181 check_fields(&returns.fields, &defined)?;
182 }
183 }
184 for event in &channel.events {
185 check_fields(&event.fields, &defined)?;
186 }
187 }
188 }
189 Ok(())
190}
191
192fn check_fields(fields: &[ir::Field], defined: &DefinedNames) -> Result<(), ParseError> {
193 for field in fields {
194 check_ty(&field.ty, &field.name, defined)?;
195 }
196 Ok(())
197}
198
199fn check_ty(ty: &ir::Ty, field: &str, defined: &DefinedNames) -> Result<(), ParseError> {
200 match ty {
201 ir::Ty::Primitive(_) | ir::Ty::Literal(_) => Ok(()),
202 ir::Ty::Array(inner) => check_ty(inner, field, defined),
203 ir::Ty::Union(members) => members.iter().try_for_each(|m| check_ty(m, field, defined)),
204 ir::Ty::Named(name) if defined.values.contains(name.as_str()) => Ok(()),
205 ir::Ty::Named(name) => Err(ParseError::Validation(format!(
206 "field {field:?} references unknown type {name:?}; \
207 define it as a `struct` or `enum`, or use `link<{name}>` for a `record`"
208 ))),
209 ir::Ty::Link(name) if defined.records.contains(name.as_str()) => Ok(()),
210 ir::Ty::Link(name) => Err(ParseError::Validation(format!(
211 "field {field:?} links to unknown record {name:?}; \
212 define it as a `record`"
213 ))),
214 }
215}
216
217fn lower_struct(raw: raw::RawStruct) -> Result<ir::TypeDef, ParseError> {
218 Ok(ir::TypeDef::Struct {
219 name: raw.name,
220 description: raw.description,
221 fields: lower_fields(raw.fields)?,
222 })
223}
224
225fn lower_enum(raw: raw::RawEnum) -> ir::TypeDef {
226 ir::TypeDef::Enum {
227 name: raw.name,
228 description: raw.description,
229 variants: raw.variants.into_iter().map(|v| v.name).collect(),
230 }
231}
232
233fn lower_record(raw: raw::RawRecord) -> Result<ir::Record, ParseError> {
234 let id_strategy = lower_id_strategy(raw.id.and_then(|i| i.strategy).as_deref())?;
235 Ok(ir::Record {
236 name: raw.name,
237 description: raw.description,
238 id_strategy,
239 fields: lower_fields(raw.fields)?,
240 })
241}
242
243fn lower_relation(raw: raw::RawRelation) -> Result<ir::Relation, ParseError> {
244 Ok(ir::Relation {
245 name: raw.name,
246 description: raw.description,
247 from: raw.from,
248 to: raw.to,
249 unique: raw.unique,
250 fields: lower_fields(raw.fields)?,
251 })
252}
253
254fn lower_id_strategy(s: Option<&str>) -> Result<ir::IdStrategy, ParseError> {
255 match s {
256 None | Some("uuidv7") => Ok(ir::IdStrategy::Uuidv7),
257 Some("ulid") => Ok(ir::IdStrategy::Ulid),
258 Some("manual") => Ok(ir::IdStrategy::Manual),
259 Some(other) => Err(ParseError::Validation(format!(
260 "unknown id `strategy` value {other:?} (expected uuidv7/ulid/manual)"
261 ))),
262 }
263}
264
265fn lower_protocol(raw: raw::RawProtocol) -> Result<ir::Protocol, ParseError> {
266 let channels = raw
267 .channels
268 .into_iter()
269 .map(lower_channel)
270 .collect::<Result<Vec<_>, _>>()?;
271 Ok(ir::Protocol {
272 name: raw.name,
273 version: raw.version,
274 namespace: raw.namespace,
275 description: raw.description,
276 channels,
277 })
278}
279
280fn lower_channel(raw: raw::RawChannel) -> Result<ir::Channel, ParseError> {
281 let from = lower_channel_from(&raw.from)?;
282 let lifetime = lower_channel_lifetime(&raw.lifetime)?;
283 let backend = lower_channel_backend(raw.backend.as_deref())?;
284 let requests = raw
285 .requests
286 .into_iter()
287 .map(lower_request)
288 .collect::<Result<Vec<_>, _>>()?;
289 let events = raw
290 .events
291 .into_iter()
292 .map(lower_event)
293 .collect::<Result<Vec<_>, _>>()?;
294
295 if backend == ir::ChannelBackend::Datagram {
297 match raw.channel_id {
298 None => {
299 return Err(ParseError::Validation(format!(
300 "channel {:?} has backend=\"datagram\" but no channel_id; \
301 channel_id=N (1..) is required",
302 raw.name
303 )));
304 }
305 Some(0) => {
306 return Err(ParseError::Validation(format!(
307 "channel {:?} has channel_id=0 which is reserved; use 1..",
308 raw.name
309 )));
310 }
311 Some(_) => {}
312 }
313 if !requests.is_empty() {
314 return Err(ParseError::Validation(format!(
315 "channel {:?} has backend=\"datagram\" with request blocks; \
316 datagram channels support event only (no Request/Response)",
317 raw.name
318 )));
319 }
320 }
321
322 Ok(ir::Channel {
323 name: raw.name,
324 from,
325 lifetime,
326 backend,
327 channel_id: raw.channel_id,
328 envelope: raw.envelope,
329 requests,
330 events,
331 })
332}
333
334fn lower_request(raw: raw::RawRequest) -> Result<ir::Request, ParseError> {
335 Ok(ir::Request {
336 name: raw.name,
337 fields: lower_fields(raw.fields)?,
338 returns: raw.returns.map(lower_message).transpose()?,
339 })
340}
341
342fn lower_event(raw: raw::RawEvent) -> Result<ir::Event, ParseError> {
343 Ok(ir::Event {
344 name: raw.name,
345 fields: lower_fields(raw.fields)?,
346 })
347}
348
349fn lower_message(raw: raw::RawMessage) -> Result<ir::Message, ParseError> {
350 Ok(ir::Message {
351 name: raw.name,
352 fields: lower_fields(raw.fields)?,
353 })
354}
355
356fn lower_fields(raw: Vec<raw::RawField>) -> Result<Vec<ir::Field>, ParseError> {
357 raw.into_iter().map(lower_field).collect()
358}
359
360fn lower_field(raw: raw::RawField) -> Result<ir::Field, ParseError> {
361 Ok(ir::Field {
362 ty: parse_ty(&raw.type_str)?,
363 name: raw.name,
364 required: !raw.optional,
365 flexible: raw.flexible,
366 default: raw.default,
367 description: raw.description,
368 constraints: ir::Constraints {
369 min: raw.min,
370 max: raw.max,
371 min_length: raw.min_length,
372 max_length: raw.max_length,
373 pattern: raw.pattern,
374 },
375 })
376}
377
378fn lower_channel_from(s: &str) -> Result<ir::ChannelFrom, ParseError> {
383 match s {
384 "client" => Ok(ir::ChannelFrom::Client),
385 "server" => Ok(ir::ChannelFrom::Server),
386 "either" => Ok(ir::ChannelFrom::Either),
387 other => Err(ParseError::Validation(format!(
388 "unknown channel `from` value {other:?} (expected client/server/either)"
389 ))),
390 }
391}
392
393fn lower_channel_lifetime(s: &str) -> Result<ir::ChannelLifetime, ParseError> {
394 match s {
395 "transient" => Ok(ir::ChannelLifetime::Transient),
396 "persistent" => Ok(ir::ChannelLifetime::Persistent),
397 other => Err(ParseError::Validation(format!(
398 "unknown channel `lifetime` value {other:?} (expected transient/persistent)"
399 ))),
400 }
401}
402
403fn lower_channel_backend(s: Option<&str>) -> Result<ir::ChannelBackend, ParseError> {
404 match s {
405 None | Some("stream") => Ok(ir::ChannelBackend::Stream),
406 Some("datagram") => Ok(ir::ChannelBackend::Datagram),
407 Some(other) => Err(ParseError::Validation(format!(
408 "unknown channel `backend` value {other:?} (expected stream/datagram)"
409 ))),
410 }
411}
412
413fn parse_ty(s: &str) -> Result<ir::Ty, ParseError> {
428 let s = s.trim();
429 if s.is_empty() {
430 return Err(ParseError::Validation("empty field type".to_string()));
431 }
432
433 let members = split_top_level_union(s);
435 if members.len() > 1 {
436 let parsed = members
437 .iter()
438 .map(|m| parse_ty(m))
439 .collect::<Result<Vec<_>, _>>()?;
440 return Ok(ir::Ty::Union(parsed));
441 }
442
443 parse_atom(s)
444}
445
446fn parse_atom(s: &str) -> Result<ir::Ty, ParseError> {
448 let s = s.trim();
449 if let Some(inner) = s.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
450 return Ok(ir::Ty::Array(Box::new(parse_ty(inner)?)));
451 }
452 if let Some(inner) = s.strip_prefix("link<").and_then(|r| r.strip_suffix('>')) {
453 let name = inner.trim();
454 if name.is_empty() {
455 return Err(ParseError::Validation(
456 "empty record name in `link<>`".to_string(),
457 ));
458 }
459 return Ok(ir::Ty::Link(name.to_string()));
460 }
461 if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
463 return Ok(ir::Ty::Literal(inner.to_string()));
464 }
465 let prim = match s {
466 "string" => Some(ir::Prim::String),
467 "int" => Some(ir::Prim::Int),
468 "float" | "number" => Some(ir::Prim::Float),
469 "bool" => Some(ir::Prim::Bool),
470 "datetime" | "timestamp" => Some(ir::Prim::Datetime),
471 "json" | "object" => Some(ir::Prim::Json),
472 _ => None,
473 };
474 match prim {
475 Some(p) => Ok(ir::Ty::Primitive(p)),
476 None if s.is_empty() => Err(ParseError::Validation("empty field type".to_string())),
477 None => Ok(ir::Ty::Named(s.to_string())),
478 }
479}
480
481fn split_top_level_union(s: &str) -> Vec<String> {
484 let mut parts: Vec<String> = Vec::new();
485 let mut cur = String::new();
486 let mut depth: usize = 0;
487 let mut in_literal = false;
488 for c in s.chars() {
489 match c {
490 '\'' => {
491 in_literal = !in_literal;
492 cur.push(c);
493 }
494 '<' if !in_literal => {
495 depth += 1;
496 cur.push(c);
497 }
498 '>' if !in_literal => {
499 depth = depth.saturating_sub(1);
500 cur.push(c);
501 }
502 '|' if depth == 0 && !in_literal => {
503 parts.push(cur.trim().to_string());
504 cur.clear();
505 }
506 _ => cur.push(c),
507 }
508 }
509 parts.push(cur.trim().to_string());
510 parts
511}
512
513#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn parses_a_protocol_with_request_and_returns() {
523 let src = r#"
524 protocol "ping-pong" version="2.0.0" {
525 namespace "test.pp"
526 channel "pp" from="client" lifetime="persistent" {
527 request "Ping" {
528 field "message" type="string"
529 returns "Pong" {
530 field "reply" type="string"
531 }
532 }
533 }
534 }
535 "#;
536 let schema = parse(src).expect("should parse");
537 let protocol = schema.protocol.expect("has protocol");
538 assert_eq!(protocol.name, "ping-pong");
539 assert_eq!(protocol.version, "2.0.0");
540 assert_eq!(protocol.namespace.as_deref(), Some("test.pp"));
541 assert_eq!(protocol.channels.len(), 1);
542
543 let channel = &protocol.channels[0];
544 assert_eq!(channel.name, "pp");
545 assert_eq!(channel.from, ir::ChannelFrom::Client);
546 assert_eq!(channel.lifetime, ir::ChannelLifetime::Persistent);
547 assert_eq!(channel.backend, ir::ChannelBackend::Stream);
548 assert_eq!(channel.requests.len(), 1);
549
550 let request = &channel.requests[0];
551 assert_eq!(request.name, "Ping");
552 assert_eq!(
553 request.fields,
554 vec![ir::Field {
555 name: "message".to_string(),
556 ty: ir::Ty::Primitive(ir::Prim::String),
557 required: true,
558 flexible: false,
559 default: None,
560 description: None,
561 constraints: ir::Constraints::default(),
562 }]
563 );
564 let returns = request.returns.as_ref().expect("has returns");
565 assert_eq!(returns.name, "Pong");
566 assert_eq!(returns.fields[0].name, "reply");
567 }
568
569 #[test]
570 fn parses_events_and_optional_fields() {
571 let src = r#"
572 protocol "p" version="1.0.0" {
573 channel "c" from="server" lifetime="persistent" {
574 event "Tick" {
575 field "seq" type="int"
576 field "note" type="string" optional=#true
577 }
578 }
579 }
580 "#;
581 let schema = parse(src).expect("should parse");
582 let channel = &schema.protocol.unwrap().channels[0];
583 let event = &channel.events[0];
584 assert_eq!(event.name, "Tick");
585 assert!(
586 event.fields[0].required,
587 "unmarked field defaults to required"
588 );
589 assert!(
590 !event.fields[1].required,
591 "explicit optional=#true → not required"
592 );
593 }
594
595 #[test]
596 fn datagram_channel_requires_channel_id() {
597 let src = r#"
598 protocol "p" version="1.0.0" {
599 channel "metric" from="server" lifetime="persistent" backend="datagram" {
600 event "M" { field "v" type="int" }
601 }
602 }
603 "#;
604 let err = parse(src).expect_err("datagram without channel_id is invalid");
605 assert!(matches!(err, ParseError::Validation(_)));
606 }
607
608 #[test]
609 fn datagram_channel_rejects_requests() {
610 let src = r#"
611 protocol "p" version="1.0.0" {
612 channel "c" from="client" lifetime="persistent" backend="datagram" channel_id=1 {
613 request "R" { field "x" type="int" }
614 }
615 }
616 "#;
617 let err = parse(src).expect_err("datagram with request is invalid");
618 assert!(matches!(err, ParseError::Validation(_)));
619 }
620
621 #[test]
622 fn parses_datagram_channel_with_id() {
623 let src = r#"
624 protocol "p" version="1.0.0" {
625 channel "metric" from="server" lifetime="persistent" backend="datagram" channel_id=7 {
626 event "M" { field "v" type="int" }
627 }
628 }
629 "#;
630 let channel = &parse(src).unwrap().protocol.unwrap().channels[0];
631 assert_eq!(channel.backend, ir::ChannelBackend::Datagram);
632 assert_eq!(channel.channel_id, Some(7));
633 }
634
635 #[test]
636 fn parses_data_dialect_struct_and_enum() {
637 let src = r#"
638 struct "User" {
639 field "id" type="string"
640 field "tags" type="array<string>"
641 field "role" type="Role"
642 }
643 enum "Role" {
644 variant "admin"
645 variant "member"
646 }
647 "#;
648 let schema = parse(src).expect("should parse");
649 assert!(schema.protocol.is_none());
650 assert_eq!(schema.types.len(), 2);
651
652 match &schema.types[0] {
653 ir::TypeDef::Struct { name, fields, .. } => {
654 assert_eq!(name, "User");
655 assert_eq!(
656 fields[1].ty,
657 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
658 );
659 assert_eq!(fields[2].ty, ir::Ty::Named("Role".to_string()));
660 }
661 other => panic!("expected struct, got {other:?}"),
662 }
663 match &schema.types[1] {
664 ir::TypeDef::Enum { name, variants, .. } => {
665 assert_eq!(name, "Role");
666 assert_eq!(variants, &["admin", "member"]);
667 }
668 other => panic!("expected enum, got {other:?}"),
669 }
670 }
671
672 #[test]
673 fn rejects_unknown_channel_from() {
674 let src = r#"
675 protocol "p" version="1.0.0" {
676 channel "c" from="nobody" lifetime="persistent" {
677 event "E" { field "x" type="int" }
678 }
679 }
680 "#;
681 let err = parse(src).expect_err("unknown from value is invalid");
682 assert!(matches!(err, ParseError::Validation(_)));
683 }
684
685 #[test]
686 fn primitive_type_aliases() {
687 assert_eq!(
688 parse_ty("object").unwrap(),
689 ir::Ty::Primitive(ir::Prim::Json)
690 );
691 assert_eq!(
692 parse_ty("number").unwrap(),
693 ir::Ty::Primitive(ir::Prim::Float)
694 );
695 assert_eq!(
696 parse_ty("timestamp").unwrap(),
697 ir::Ty::Primitive(ir::Prim::Datetime)
698 );
699 }
700
701 #[test]
702 fn rejects_unknown_type_reference() {
703 let src = r#"
704 struct "User" {
705 field "role" type="Role"
706 }
707 "#;
708 let err = parse(src).expect_err("unknown type reference is invalid");
710 assert!(matches!(err, ParseError::Validation(_)));
711 }
712
713 #[test]
714 fn accepts_array_of_defined_type() {
715 let src = r#"
716 struct "Team" {
717 field "members" type="array<User>"
718 }
719 struct "User" {
720 field "id" type="string"
721 }
722 "#;
723 parse(src).expect("array<User> with User defined should parse");
725 }
726
727 #[test]
732 fn parses_record_with_id_strategy_and_fields() {
733 let src = r#"
734 record "Atlas" {
735 id strategy="uuidv7"
736 field "name" type="string"
737 field "parent" type="link<Atlas>"
738 }
739 "#;
740 let schema = parse(src).expect("record should parse");
741 assert_eq!(schema.records.len(), 1);
742 let atlas = &schema.records[0];
743 assert_eq!(atlas.name, "Atlas");
744 assert_eq!(atlas.id_strategy, ir::IdStrategy::Uuidv7);
745 assert_eq!(atlas.fields[0].name, "name");
746 assert_eq!(atlas.fields[1].ty, ir::Ty::Link("Atlas".to_string()));
748 }
749
750 #[test]
751 fn record_id_strategy_defaults_to_uuidv7_when_absent() {
752 let src = r#"
753 record "Note" {
754 field "body" type="string"
755 }
756 "#;
757 let schema = parse(src).expect("record without `id` node should parse");
758 assert_eq!(schema.records[0].id_strategy, ir::IdStrategy::Uuidv7);
759 }
760
761 #[test]
762 fn parses_all_id_strategies() {
763 for (kw, expected) in [
764 ("ulid", ir::IdStrategy::Ulid),
765 ("manual", ir::IdStrategy::Manual),
766 ("uuidv7", ir::IdStrategy::Uuidv7),
767 ] {
768 let src = format!(
769 r#"record "R" {{ id strategy="{kw}"
770 field "x" type="string" }}"#
771 );
772 let schema = parse(&src).expect("record parses");
773 assert_eq!(schema.records[0].id_strategy, expected);
774 }
775 }
776
777 #[test]
778 fn rejects_unknown_id_strategy() {
779 let src = r#"record "R" { id strategy="snowflake"
780 field "x" type="string" }"#;
781 let err = parse(src).expect_err("unknown id strategy is invalid");
782 assert!(matches!(err, ParseError::Validation(_)));
783 }
784
785 #[test]
786 fn parses_relation_with_endpoints_and_edge_fields() {
787 let src = r#"
788 record "Memory" {
789 field "body" type="string"
790 }
791 relation "derivedFrom" from="Memory" to="Memory" unique=#true {
792 field "confidence" type="float"
793 field "reason" type="string"
794 }
795 "#;
796 let schema = parse(src).expect("relation should parse");
797 assert_eq!(schema.relations.len(), 1);
798 let rel = &schema.relations[0];
799 assert_eq!(rel.name, "derivedFrom");
800 assert_eq!(rel.from, "Memory");
801 assert_eq!(rel.to, "Memory");
802 assert!(rel.unique);
803 assert_eq!(rel.fields.len(), 2);
804 assert_eq!(rel.fields[0].name, "confidence");
805 }
806
807 #[test]
808 fn relation_unique_defaults_to_false() {
809 let src = r#"
810 record "A" { field "x" type="string" }
811 relation "rel" from="A" to="A"
812 "#;
813 let schema = parse(src).expect("relation parses");
814 assert!(!schema.relations[0].unique);
815 }
816
817 #[test]
818 fn rejects_relation_with_unknown_endpoint() {
819 let src = r#"
820 record "A" { field "x" type="string" }
821 relation "rel" from="A" to="Ghost"
822 "#;
823 let err = parse(src).expect_err("unknown relation endpoint is invalid");
824 assert!(matches!(err, ParseError::Validation(_)));
825 }
826
827 #[test]
828 fn parse_ty_link_and_literal_and_union() {
829 assert_eq!(
830 parse_ty("link<Atlas>").unwrap(),
831 ir::Ty::Link("Atlas".to_string())
832 );
833 assert_eq!(
834 parse_ty("'public'").unwrap(),
835 ir::Ty::Literal("public".to_string())
836 );
837 assert_eq!(
838 parse_ty("'public' | 'private'").unwrap(),
839 ir::Ty::Union(vec![
840 ir::Ty::Literal("public".to_string()),
841 ir::Ty::Literal("private".to_string()),
842 ])
843 );
844 assert_eq!(
845 parse_ty("string | int | bool").unwrap(),
846 ir::Ty::Union(vec![
847 ir::Ty::Primitive(ir::Prim::String),
848 ir::Ty::Primitive(ir::Prim::Int),
849 ir::Ty::Primitive(ir::Prim::Bool),
850 ])
851 );
852 }
853
854 #[test]
855 fn union_does_not_split_inside_brackets() {
856 assert_eq!(
859 parse_ty("array<string>").unwrap(),
860 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
861 );
862 }
863
864 #[test]
865 fn link_to_unknown_record_is_rejected() {
866 let src = r#"
867 record "Atlas" {
868 field "parent" type="link<Ghost>"
869 }
870 "#;
871 let err = parse(src).expect_err("link to undefined record is invalid");
872 assert!(matches!(err, ParseError::Validation(_)));
873 }
874
875 #[test]
876 fn flexible_and_default_properties_are_lowered() {
877 let src = r#"
878 record "Atlas" {
879 field "metadata" type="object" flexible=#true
880 field "visibility" type="'public' | 'private'" default="private"
881 }
882 "#;
883 let schema = parse(src).expect("record should parse");
884 let fields = &schema.records[0].fields;
885 assert!(fields[0].flexible, "flexible=#true is lowered");
886 assert!(!fields[1].flexible, "absent flexible defaults false");
887 assert_eq!(fields[1].default.as_deref(), Some("private"));
888 }
889
890 #[test]
891 fn bare_name_is_embedded_link_is_stored() {
892 let src = r#"
895 struct "GeoPoint" {
896 field "lat" type="float"
897 }
898 record "Place" {
899 field "at" type="GeoPoint"
900 field "parent" type="link<Place>"
901 }
902 "#;
903 let schema = parse(src).expect("schema parses");
904 let fields = &schema.records[0].fields;
905 assert_eq!(fields[0].ty, ir::Ty::Named("GeoPoint".to_string()));
906 assert_eq!(fields[1].ty, ir::Ty::Link("Place".to_string()));
907 }
908
909 #[test]
914 fn record_and_field_descriptions_are_lowered() {
915 let src = r#"
916 record "Memory" description="User memory with content" {
917 field "content" type="string" description="Memory content text"
918 }
919 "#;
920 let schema = parse(src).expect("record with descriptions parses");
921 let memory = &schema.records[0];
922 assert_eq!(
923 memory.description.as_deref(),
924 Some("User memory with content")
925 );
926 assert_eq!(
927 memory.fields[0].description.as_deref(),
928 Some("Memory content text")
929 );
930 }
931
932 #[test]
933 fn struct_enum_relation_descriptions_are_lowered() {
934 let src = r#"
935 struct "Point" description="A 2D point" {
936 field "x" type="float"
937 }
938 enum "Color" description="An RGB primary" {
939 variant "red"
940 }
941 record "Node" { field "v" type="int" }
942 relation "edge" from="Node" to="Node" description="A directed edge"
943 "#;
944 let schema = parse(src).expect("schema parses");
945 match &schema.types[0] {
946 ir::TypeDef::Struct { description, .. } => {
947 assert_eq!(description.as_deref(), Some("A 2D point"));
948 }
949 other => panic!("expected struct, got {other:?}"),
950 }
951 match &schema.types[1] {
952 ir::TypeDef::Enum { description, .. } => {
953 assert_eq!(description.as_deref(), Some("An RGB primary"));
954 }
955 other => panic!("expected enum, got {other:?}"),
956 }
957 assert_eq!(
958 schema.relations[0].description.as_deref(),
959 Some("A directed edge")
960 );
961 }
962
963 #[test]
964 fn field_constraints_are_lowered() {
965 let src = r#"
966 struct "Profile" {
967 field "confidence" type="float" min=0 max=1
968 field "name" type="string" min_length=1 max_length=32 pattern="^[a-z]+$"
969 }
970 "#;
971 let schema = parse(src).expect("schema parses");
972 let fields = match &schema.types[0] {
973 ir::TypeDef::Struct { fields, .. } => fields,
974 other => panic!("expected struct, got {other:?}"),
975 };
976 assert_eq!(fields[0].constraints.min, Some(0));
977 assert_eq!(fields[0].constraints.max, Some(1));
978 assert_eq!(fields[1].constraints.min_length, Some(1));
979 assert_eq!(fields[1].constraints.max_length, Some(32));
980 assert_eq!(fields[1].constraints.pattern.as_deref(), Some("^[a-z]+$"));
981 }
982
983 #[test]
984 fn absent_constraints_and_description_default_to_none() {
985 let src = r#"
986 struct "Bare" {
987 field "x" type="int"
988 }
989 "#;
990 let schema = parse(src).expect("schema parses");
991 let fields = match &schema.types[0] {
992 ir::TypeDef::Struct { fields, .. } => fields,
993 other => panic!("expected struct, got {other:?}"),
994 };
995 assert!(fields[0].description.is_none());
996 assert!(
997 fields[0].constraints.is_empty(),
998 "a field with no constraint properties has empty Constraints"
999 );
1000 }
1001}