1mod raw;
18
19use std::collections::HashSet;
20
21use crate::ir;
22
23#[derive(Debug, thiserror::Error)]
25pub enum ParseError {
26 #[error("KDL parse error: {0}")]
28 Kdl(String),
29 #[error("schema validation error: {0}")]
31 Validation(String),
32}
33
34pub 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
47fn 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
80struct DefinedNames<'a> {
85 values: HashSet<&'a str>,
87 records: HashSet<&'a str>,
89}
90
91fn 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 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 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
329fn 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
364fn 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 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
397fn 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 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
432fn 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#[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 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 parse(src).expect("array<User> with User defined should parse");
676 }
677
678 #[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 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 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 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 #[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}